docs: spec Phase 22 — parameterized SEO metadata component (public site)
This commit is contained in:
@@ -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 `<HeadOutlet @rendermode="InteractiveAuto">` and a static `<head>`,
|
||||||
|
but **no** description, canonical, OG, Twitter, or JSON-LD anywhere; pages set only an ad-hoc
|
||||||
|
`<PageTitle>` (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 `<PageTitle>` + `<HeadContent>`; 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
|
## Working with this file
|
||||||
|
|||||||
@@ -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 `<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.
|
||||||
Reference in New Issue
Block a user