From 2653e62eeb22a0e3b38a94ed86e51b40f56766a3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 06:21:52 -0400 Subject: [PATCH] docs: reflect Phase 22 SEO metadata component as landed --- CLAUDE.md | 2 +- COMPLETED.md | 22 ++++++++++ DeepDrftPublic.Client/CLAUDE.md | 8 ++++ PLAN.md | 77 --------------------------------- 4 files changed, 31 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e567793..6385b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a ### Core Projects - **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners. -- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site. +- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. **SEO component** (`Controls/SeoHead.razor` + `Common/SeoModel`, `SeoJsonLd`, `SeoOptions`, `SeoUrls`, `SeoEnvironment`): `SeoHead` is a presentational `` emitter (one line per page, no fetch); `SeoModel` named factories (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`/`ForNotFound`) encode the medium→schema.org mapping in one place; `SeoJsonLd` builds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping; `SeoOptions` holds site-wide config (`BaseUrl https://deepdrft.com`, title suffix, default OG image seam, IG `sameAs`) registered via the static `Startup` seam; `SeoEnvironment` is a scoped `[PersistentState]` bridge (mirrors `DarkModeSettings`) seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — robots defaults to `index,follow` only in Production, `noindex,nofollow` everywhere else (fail-safe is noindex); per-page `SeoModel.Robots` overrides the default. Tags are present in prerendered HTML (rides the existing `PersistentComponentState` bridge; no new fetch). Canonical/OG origins come from `SeoOptions.BaseUrl` (config), not `window.location` — no `window` at server prerender and the origin cannot be derived behind the nginx proxy. Consumed by the public site. - **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release. - **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces. - **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests. diff --git a/COMPLETED.md b/COMPLETED.md index 46e8e96..2d293c0 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -6,6 +6,28 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM --- +## Phase 22 — SEO Metadata Component (landed 2026-06-23) + +**Landed:** 2026-06-23 on dev. + +- **What:** A parameterized, reusable SEO head component (`SeoHead.razor`) that emits the full modern-SEO head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org JSON-LD — for every public page in one line of markup. **Public listener site only** (`DeepDrftPublic` host + `DeepDrftPublic.Client`); the CMS is explicitly out of scope. No data-model/schema change, no new API endpoint. + +- **Why:** `App.razor` had a static `` with no description, canonical, OG, Twitter Card, or JSON-LD anywhere; pages set only an ad-hoc `` with an inconsistent suffix. A shared `/mixes/{key}` link unfurled as a bare title + URL. Crawlers and social unfurlers saw nothing useful. + +- **Shape:** + - **`Controls/SeoHead.razor`** (new): purely presentational `` + `` emitter. Accepts a single `SeoModel` parameter; owns no data fetch. Each page wires it in one line. + - **`Common/SeoModel.cs`** (new): typed per-page input with named factories — `ForRelease(release, baseUrl, options)` (medium-dispatched), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Factories encode the medium→schema mapping in one place. Explicit `SeoModel.Robots` override available; default is environment-gated (see `SeoEnvironment`). + - **`Common/SeoJsonLd.cs`** (new): typed schema.org JSON-LD builders. Cut → `MusicAlbum` with ordered `MusicRecording` track list; Session → `MusicAlbum`/`LiveAlbum`; Mix → single `MusicRecording` with ISO-8601 duration; Home/About → `MusicGroup` (with `sameAs: ["https://instagram.com/deepdrft.music"]`); Browse → `CollectionPage`. `byArtist` wired per-release. JSON-LD body is inline-safe-escaped (`<`/`>`/`&` → `\uXXXX`) to prevent script-breakout from CMS-authored text. + - **`Common/SeoOptions.cs`** (new): site-wide config — `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (runs in both server and WASM `Program.cs`). + - **`Common/SeoUrls.cs`** (new): URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (config, not `window.location` — no `window` at server prerender and the origin can't be derived behind the nginx proxy). + - **`Common/SeoEnvironment.cs`** (new): scoped `[PersistentState]` bridge seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`. + - **Wired into:** Home, About, Cut/Session/Mix detail pages (incl. their not-found branches → `noindex`), the browse views (Albums/Sessions/Mixes/Archive), and the 404 NotFound page. + - **Render-mode correctness:** `SeoHead` rides the existing `PersistentComponentState` bridge (the same `ReleaseDto` the detail pages already bridge) — no new fetch. The `InteractiveAuto` double-render produces identical head content across prerender and WASM passes (fed from bridged state, guarded on id/key equality). + +- **Design memo:** `product-notes/phase-22-seo-metadata-component.md`. + +--- + ## Phase 20 — Theater Mode (landed 2026-06-20) **Landed:** 2026-06-20 on dev. Pending: final manual browser/GPU smoke-test on dev. diff --git a/DeepDrftPublic.Client/CLAUDE.md b/DeepDrftPublic.Client/CLAUDE.md index 19ab52a..97468d7 100644 --- a/DeepDrftPublic.Client/CLAUDE.md +++ b/DeepDrftPublic.Client/CLAUDE.md @@ -40,6 +40,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream - `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred). - `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `` + `` here; Mix uses its own full-bleed mount outside the scaffold. The scaffold's default masthead PLAY (`PlayTrack`) routes through `IQueueService.PlayTrack` (deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back to `IStreamingPlayerService.SelectTrackStreaming` when absent; toggle-pause is handled directly via `IStreamingPlayerService.TogglePlayPause` when this track is already active. - `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay. + - `SeoHead.razor`: Purely presentational SEO head emitter (Phase 22). Renders a `` + `` block from a single `SeoModel` parameter — standard meta (description, robots), canonical, Open Graph, Twitter Card, and schema.org JSON-LD. Owns no data fetch; each page wires it in one line and supplies the model from its already-bridged ViewModel state. Wired on Home, About, Cut/Session/Mix detail (incl. not-found branches → `noindex`), browse views, and the 404 page. - `Helpers/`: Utilities and mapper functions. - `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple. - `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure. @@ -70,6 +71,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream - `Common/`: Shared utilities. - `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped. - `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B). + - `SeoModel.cs`: Typed per-page SEO input (Phase 22). Named factories: `ForRelease` (medium-dispatched — Cut → `MusicAlbum`, Session → `MusicAlbum`/`LiveAlbum`, Mix → `MusicRecording`), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Encodes the medium→schema.org mapping in one place. `SeoModel.Robots` overrides the environment-default (see `SeoEnvironment`). + - `SeoJsonLd.cs`: Typed schema.org JSON-LD builders (Phase 22). Types: `MusicGroup` (home/about, with `sameAs: ["https://instagram.com/deepdrft.music"]`), `MusicAlbum`/`LiveAlbum` (cuts/sessions, with ordered `MusicRecording` track list and per-release `byArtist`), `MusicRecording` (mixes, with ISO-8601 `duration`), `CollectionPage` (browse). All serialized output is inline-safe-escaped (`<`/`>`/`&` → `\uXXXX`) to prevent script-breakout from CMS-authored text. + - `SeoOptions.cs`: Site-wide SEO config (Phase 22). `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (both server and WASM `Program.cs`). `BaseUrl` is config, not `window.location` — no `window` at server prerender, and the origin cannot be derived reliably behind the nginx proxy. + - `SeoUrls.cs`: URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (Phase 22). + - `SeoEnvironment.cs`: Scoped `[PersistentState]` bridge for the server environment flag (Phase 22). Seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge pattern. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`. - `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`. - `_Imports.razor`: Global using statements and component imports. @@ -129,6 +135,8 @@ New modules in `DeepDrftPublic/Interop/audio/`: The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie. +**`SeoEnvironment` follows the same `[PersistentState]` bridge pattern** (Phase 22). It is seeded server-side in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` and bridged to the WASM client. Consumers (`SeoHead`) read `SeoEnvironment.IsProduction` to gate the default robots directive (`index,follow` in Production, `noindex,nofollow` elsewhere). The pattern is identical to `DarkModeSettings` — one server-side seed, one `PersistentComponentState` round-trip, one scoped client read. + ## MVVM convention Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only. diff --git a/PLAN.md b/PLAN.md index 8220acc..8dafefb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -653,83 +653,6 @@ 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