docs: spec Phase 22 — parameterized SEO metadata component (public site)

This commit is contained in:
daniel-c-harvey
2026-06-23 05:12:31 -04:00
parent 1bdaeaa164
commit 6af6677a12
2 changed files with 515 additions and 0 deletions
+76
View File
@@ -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.122.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