440 lines
31 KiB
Markdown
440 lines
31 KiB
Markdown
# 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 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.
|