diff --git a/PLAN.md b/PLAN.md index 2c6d9fe..aab4855 100644 --- a/PLAN.md +++ b/PLAN.md @@ -618,6 +618,82 @@ convention.** None block 21.1. --- +## Phase 22 — SEO Metadata Component (parameterized head/meta injection) + +Give every public page a **single reusable, parameterized component** (`SeoHead`) 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. **Public listener site only** (`DeepDrftPublic` host + +`DeepDrftPublic.Client`); the CMS (`DeepDrftManager`) is an authenticated admin surface and is +**explicitly out of scope**. No data-model/schema change, no new API endpoint — every value is already +on `ReleaseDto` / `TrackDto` / `HomeStatsDto`. + +**The gap today.** `App.razor` has a `` and a static ``, +but **no** description, canonical, OG, Twitter, or JSON-LD anywhere; pages set only an ad-hoc +`` (and the suffix is already inconsistent — `- DeepDrft` vs `- Electronic Music Collective`). +A shared `/mixes/{key}` link unfurls as a bare title + URL. + +**Shape — one component + a typed model + a config (SOLID).** `SeoHead.razor` (presentational, in +`DeepDrftPublic.Client`, renders a `` + ``; owns no fetch); a `SeoModel` typed +per-page input with **named factories** (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`) that encode the +medium→schema mapping in one place; and `SeoOptions` site-wide defaults (site name, suffix, default +description, canonical `BaseUrl`, default OG image, social handles) registered via the static-`Startup` +seam that runs in both server and WASM `Program.cs`. Each page touches **one line** — the boilerplate +(~15 tags) lives once in `SeoHead` + the factories. The component is **presentational and parameter-fed** +exactly like `ReleaseHeroOverlay`/`ReleaseDescription`; the page's ViewModel already holds the DTO. + +**Music-domain JSON-LD (the high-leverage part).** Per-medium schema.org mapping: a **cut** → +`MusicAlbum` with an ordered `track` list (`MusicRecording`s); a **session** → `MusicAlbum`/`LiveAlbum` +(treated as a release, not a calendar event — OQ6); a **mix** → a single `MusicRecording` with ISO-8601 +`duration`; home/about → the `MusicGroup` entity; browse → `CollectionPage`. Recommend a **typed JSON-LD +builder** (small schema-shaped C# records, serialized) over passed raw fragments — it is the only option +that keeps DRY and makes Rich-Results validity a unit test (OQ5). + +**Render-mode correctness (the load-bearing requirement).** Crawlers read prerendered HTML and do not run +WASM, so the tags must be present at **prerender time**. This works because the SEO data is a *projection +of the same `ReleaseDto` the detail pages already resolve during prerender and bridge across the WASM seam +via `PersistentComponentState`* — `SeoHead` rides that existing bridge, no new fetch. Two care points, +both spelled out in the spec: (1) the `InteractiveAuto` double-render must produce **identical** head +content across the prerender and WASM passes (fed from bridged state, not a fresh client fetch — guard on +id/key equality like the detail pages do), and (2) absolute canonical/`og:url`/`og:image` origins come +from `SeoOptions.BaseUrl` (config), **not** a browser-only `window.location` — there is no `window` at +server prerender, and the origin can't be reliably derived behind the nginx proxy. + +**Open questions for Daniel (spec §7):** canonical production origin/`BaseUrl` (OQ1 — must be config, can't +be derived behind the proxy); default OG share image asset (OQ2); social handles / `sameAs` (OQ3); title +suffix + composition (OQ4 — resolves the existing inconsistency); typed JSON-LD builder vs. passed +fragment (OQ5 — recommend typed); session schema type (OQ6 — recommend `MusicAlbum`/`LiveAlbum`). +**Adjacent but separate (flagged, not in this component):** `robots.txt` (static, disallow the CMS host), +`sitemap.xml` (needs a small public-host endpoint enumerating releases — reuses the existing paged read), +and CMS `noindex` (a CMS-side robots/meta change). Daniel to call whether sitemap folds into Phase 22 as a +second wave or tracks as its own phase. + +Full design — the metadata-surface table, the component contract, the build-vs-pass JSON-LD fork, the +render-mode analysis, use cases, acceptance criteria, and wave decomposition: +`product-notes/phase-22-seo-metadata-component.md`. + +Sequenced as four waves. `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 static pages (cold-start).** Build `SeoHead`, + `SeoModel` (+ standard/OG/Twitter rendering), and `SeoOptions`; wire **Home and About first** (data + trivially available at prerender, no double-render subtlety). Proves prerender emission end to end. + **Needs OQ1/OQ2/OQ4 config values — can stub and swap in Daniel's answers.** +- **22.2 — Release detail pages + per-medium JSON-LD (the rich case).** Add the `MusicGroup`/`MusicAlbum`/ + `MusicRecording` builders and the `ForRelease` factory's medium→type mapping; wire `CutDetail`/ + `SessionDetail`/`MixDetail` from their already-bridged `ReleaseDto`. Exercises the schema, double-render + identity, and canonical correctness ACs. **Depends on 22.1; needs OQ5/OQ6.** +- **22.3 — Browse + 404 + remaining pages.** `CollectionPage` for browse; `noindex` for 404. **Depends on + 22.1.** +- **22.4 — Validation pass.** No-JS crawler-view fetch (tags present), Rich Results validator, double- + render-identity check, canonical/alias matrix, partial-data releases. Largely measurement. **Depends on + 22.1–22.3.** +- **(Adjacent, separate) — `robots.txt` + `sitemap.xml`.** Endpoint-shaped follow-on, not a component + wave; tracked separately pending Daniel's fold-in vs. separate-phase call. + +**Dependency shape:** `22.1 → 22.2 → 22.3 → 22.4`; 22.1 is the only cold-start wave. None of the open +questions block 22.1 (config values can be stubbed). **No CMS change in any wave (hard constraint C1/AC9).** + --- ## Working with this file diff --git a/product-notes/phase-22-seo-metadata-component.md b/product-notes/phase-22-seo-metadata-component.md new file mode 100644 index 0000000..5831c07 --- /dev/null +++ b/product-notes/phase-22-seo-metadata-component.md @@ -0,0 +1,439 @@ +# 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 `` and nothing else; Phase 22 +replaces that with one `` 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 `` + in ``, and a static `` block with charset, viewport, ``, stylesheet links, + ``, and a favicon. **No** ``, **no** canonical, **no** OG/Twitter + tags, **no** JSON-LD. The only per-page head contribution anywhere is ``. +- Pages set titles ad hoc: `Home.razor` → `Deep DRFT - Electronic Music Collective`; + `CutDetail.razor` → `@(ViewModel.Release?.Title ?? "Cut") - DeepDrft`. No shared + title-composition convention — the suffix (" - DeepDrft" vs " - Electronic Music Collective") is + already inconsistent. +- `` 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 `` into the existing ``). +- **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 ``, 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 AC1–AC9: 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.1–22.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.