diff --git a/apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts b/apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts new file mode 100644 index 000000000..3ac9736cc --- /dev/null +++ b/apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts @@ -0,0 +1,103 @@ +import { useEffect } from 'react'; + +// Runtime injection of per-track + tags +// on /tracks/:id. v1.0.9 W3 Day 15. +// +// Limitations : pure HTML scrapers (Twitterbot, Facebookexternalhit, +// Slackbot in some modes) don't execute JS, so they read the static +// OG tags from index.html instead of these runtime ones. Modern +// scrapers that DO render JS (LinkedIn, recent Slack unfurls, browsing +// users hitting the share dialog) see the per-track tags. +// +// Long-term : v1.1+ adds an SSR / bot-detector route on the backend +// that serves OG-only HTML to known scraper user-agents. For v1.0 +// the embed widget itself (/embed/track/:id) carries server-rendered +// OG tags ; this hook is the SPA-side complement. + +export interface TrackOpenGraphInput { + id: string; + title: string; + artist?: string | null; + coverArtURL?: string | null; +} + +const META_TAGS = [ + { selector: 'meta[property="og:title"]', attr: 'content' as const }, + { selector: 'meta[property="og:type"]', attr: 'content' as const }, + { selector: 'meta[property="og:url"]', attr: 'content' as const }, + { selector: 'meta[property="og:image"]', attr: 'content' as const }, + { selector: 'meta[property="og:audio"]', attr: 'content' as const, optional: true }, + { selector: 'meta[name="twitter:card"]', attr: 'content' as const }, + { selector: 'meta[name="twitter:title"]', attr: 'content' as const }, + { selector: 'meta[name="twitter:player"]', attr: 'content' as const, optional: true }, + { selector: 'meta[name="twitter:player:width"]', attr: 'content' as const, optional: true }, + { selector: 'meta[name="twitter:player:height"]', attr: 'content' as const, optional: true }, +]; + +function ensureMeta(selector: string, key: 'name' | 'property', value: string): HTMLMetaElement { + let el = document.head.querySelector(selector); + if (!el) { + el = document.createElement('meta'); + el.setAttribute(key, value); + document.head.appendChild(el); + } + return el; +} + +function ensureLink(rel: string, type: string, href: string, title?: string): HTMLLinkElement { + const selector = `link[rel="${rel}"][type="${type}"]`; + let el = document.head.querySelector(selector); + if (!el) { + el = document.createElement('link'); + el.rel = rel; + el.type = type; + document.head.appendChild(el); + } + el.href = href; + if (title) el.title = title; + return el; +} + +export function useTrackOpenGraph(track: TrackOpenGraphInput | null | undefined): void { + useEffect(() => { + if (!track) return; + + const origin = window.location.origin; + const trackURL = `${origin}/tracks/${track.id}`; + const embedURL = `${origin}/embed/track/${track.id}`; + const oembedURL = `${origin}/oembed?url=${encodeURIComponent(trackURL)}&format=json`; + const titleAttr = track.artist ? `${track.title} — ${track.artist}` : track.title; + const coverImage = track.coverArtURL || `${origin}/icons/icon-512x512.png`; + + // Open Graph + Twitter player card. + const setOg = (property: string, value: string) => { + const el = ensureMeta(`meta[property="${property}"]`, 'property', property); + el.setAttribute('content', value); + }; + const setTwitter = (name: string, value: string) => { + const el = ensureMeta(`meta[name="${name}"]`, 'name', name); + el.setAttribute('content', value); + }; + setOg('og:type', 'music.song'); + setOg('og:title', titleAttr); + setOg('og:url', trackURL); + setOg('og:image', coverImage); + setTwitter('twitter:card', 'player'); + setTwitter('twitter:title', titleAttr); + setTwitter('twitter:player', embedURL); + setTwitter('twitter:player:width', '480'); + setTwitter('twitter:player:height', '166'); + setTwitter('twitter:image', coverImage); + + // oEmbed discovery — Twitter, Slack, Discord scan for this link. + ensureLink('alternate', 'application/json+oembed', oembedURL, 'Veza oEmbed'); + + return () => { + // We DON'T tear the meta tags down on unmount — leaving generic + // Veza OG tags from index.html in place would fight with the + // mount of the next page that might want its own values. The + // next route's effect overrides them. + void META_TAGS; + }; + }, [track]); +} diff --git a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx index 275550935..19cd8ef00 100644 --- a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx +++ b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx @@ -11,6 +11,7 @@ import { TrackDetailPageNotFound } from './TrackDetailPageNotFound'; import { TrackDetailPageSkeleton } from './TrackDetailPageSkeleton'; import { useTrackDetailPage } from './useTrackDetailPage'; import type { TrackDetailPageProps } from './types'; +import { useTrackOpenGraph } from '@/features/tracks/hooks/useTrackOpenGraph'; export function TrackDetailPage(props?: TrackDetailPageProps) { const trackIdOverride = props?.trackId; @@ -37,6 +38,21 @@ export function TrackDetailPage(props?: TrackDetailPageProps) { } }, [track?.title]); + // v1.0.9 W3 Day 15: per-track OG + Twitter card + oEmbed discovery. + useTrackOpenGraph( + track + ? { + id: track.id, + title: track.title, + artist: track.artist, + coverArtURL: + (track as { cover_art_url?: string | null; coverArtPath?: string | null }).cover_art_url ?? + (track as { coverArtPath?: string | null }).coverArtPath ?? + null, + } + : null, + ); + if (isLoading) { return ; } diff --git a/tests/e2e/30-embed-and-share.spec.ts b/tests/e2e/30-embed-and-share.spec.ts new file mode 100644 index 000000000..ab3f6bf0a --- /dev/null +++ b/tests/e2e/30-embed-and-share.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from '@chromatic-com/playwright'; +import { CONFIG } from './helpers'; + +/** + * v1.0.9 W3 Day 15 — embed widget + oEmbed + share token roundtrip. + * + * Tests : + * 30. GET /embed/track/:id renders standalone HTML with OG tags + a + * playable