A simple, clean, fast browser for the AtmosphereConf(2026) VODs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add crawler-friendly OG previews for shared video links

j4ckxyz 1e91d9f1 8777bc69

+300 -2
+26
README.md
··· 79 79 80 80 If the key is not set, search falls back gracefully to lexical title ranking. 81 81 82 + ## Open Graph preview support (Bluesky / Twitter) 83 + 84 + This repo now supports crawler-friendly OG tags in two layers: 85 + 86 + - Default static OG card from `public/og-default.png`. 87 + - Per-video HTML metadata at `functions/video/[didParam]/[rkeyParam].ts` so shared `/video/:did/:rkey` links return server-rendered meta tags for crawlers. 88 + 89 + Why this works on Cloudflare Pages: 90 + 91 + - Social crawlers usually do not execute SPA JavaScript. 92 + - Cloudflare Pages Functions can return HTML with route-specific `<meta property="og:*">` tags before the SPA loads. 93 + 94 + ### Cost profile / free-tier impact 95 + 96 + - This setup is low-cost because image generation is static (`og-default.png`) and reused. 97 + - Per-video function requests fetch one `com.atproto.repo.getRecord` for title/description and then inject tags into `index.html`. 98 + - No per-request video frame extraction, no external image API, and no persistent storage writes. 99 + 100 + ### Verify previews after deploy 101 + 102 + 1. Open any direct video URL and confirm source HTML includes route-specific `og:title`, `og:description`, and `og:url`. 103 + 2. Validate with social debuggers: 104 + - Twitter/X Card Validator: `https://cards-dev.twitter.com/validator` 105 + - OpenGraph checker: `https://www.opengraph.xyz/` 106 + 3. Paste a video URL into Bluesky compose and confirm preview card appears. 107 + 82 108 ### Freshness behavior 83 109 84 110 - Newly uploaded VODs appear immediately in results because the search function overlays a live catalog
+218
functions/video/[didParam]/[rkeyParam].ts
··· 1 + interface PagesFunctionContextLike { 2 + request: Request 3 + env: { 4 + ASSETS?: { 5 + fetch: (request: Request) => Promise<Response> 6 + } 7 + } 8 + params?: { 9 + didParam?: string 10 + rkeyParam?: string 11 + } 12 + } 13 + 14 + interface TalkMetadata { 15 + title: string 16 + description: string 17 + } 18 + 19 + const STREAMPLACE_COLLECTION = 'place.stream.video' 20 + const SITE_NAME = 'Streamplace VOD Client' 21 + const DEFAULT_DESCRIPTION = 'Browse Streamplace and AtmosphereConf VODs with deep links and fast playback.' 22 + const OG_MARKER_PATTERN = /<!--OG_META_START-->[\s\S]*?<!--OG_META_END-->/ 23 + 24 + function decodePathValue(value: string | undefined): string | null { 25 + if (!value) { 26 + return null 27 + } 28 + 29 + try { 30 + const decoded = decodeURIComponent(value).trim() 31 + return decoded || null 32 + } catch { 33 + return null 34 + } 35 + } 36 + 37 + function truncate(text: string, maxLength: number): string { 38 + if (text.length <= maxLength) { 39 + return text 40 + } 41 + 42 + return `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…` 43 + } 44 + 45 + function escapeHtml(value: string): string { 46 + return value 47 + .replaceAll('&', '&amp;') 48 + .replaceAll('<', '&lt;') 49 + .replaceAll('>', '&gt;') 50 + .replaceAll('"', '&quot;') 51 + .replaceAll("'", '&#39;') 52 + } 53 + 54 + async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number = 8_000): Promise<T> { 55 + const controller = new AbortController() 56 + const timeout = setTimeout(() => controller.abort(), timeoutMs) 57 + 58 + try { 59 + const response = await fetch(url, { signal: controller.signal }) 60 + if (!response.ok) { 61 + throw new Error(`Request failed (${response.status})`) 62 + } 63 + return (await response.json()) as T 64 + } finally { 65 + clearTimeout(timeout) 66 + } 67 + } 68 + 69 + async function resolvePdsUrl(did: string): Promise<string> { 70 + const didDoc = await fetchJsonWithTimeout<{ 71 + service?: Array<{ id?: string; serviceEndpoint?: string }> 72 + }>(`https://plc.directory/${did}`) 73 + 74 + const pdsService = didDoc.service?.find((entry) => entry.id === '#atproto_pds') 75 + if (!pdsService?.serviceEndpoint) { 76 + throw new Error('PDS endpoint not found') 77 + } 78 + 79 + return pdsService.serviceEndpoint.replace(/\/$/, '') 80 + } 81 + 82 + async function fetchTalkMetadata(videoUri: string, did: string, rkey: string): Promise<TalkMetadata | null> { 83 + try { 84 + const pdsUrl = await resolvePdsUrl(did) 85 + const query = new URLSearchParams({ 86 + repo: did, 87 + collection: STREAMPLACE_COLLECTION, 88 + rkey, 89 + uri: videoUri, 90 + }) 91 + const record = await fetchJsonWithTimeout<{ 92 + value?: { 93 + title?: string 94 + description?: string 95 + } 96 + }>(`${pdsUrl}/xrpc/com.atproto.repo.getRecord?${query.toString()}`) 97 + 98 + const title = record.value?.title?.trim() 99 + if (!title) { 100 + return null 101 + } 102 + 103 + const description = record.value?.description?.trim() || DEFAULT_DESCRIPTION 104 + 105 + return { 106 + title: truncate(title, 100), 107 + description: truncate(description, 240), 108 + } 109 + } catch { 110 + return null 111 + } 112 + } 113 + 114 + function buildOgMetaBlock(input: { 115 + title: string 116 + description: string 117 + canonicalUrl: string 118 + imageUrl: string 119 + }): string { 120 + const title = escapeHtml(input.title) 121 + const description = escapeHtml(input.description) 122 + const canonicalUrl = escapeHtml(input.canonicalUrl) 123 + const imageUrl = escapeHtml(input.imageUrl) 124 + 125 + return [ 126 + `<meta name="description" content="${description}" />`, 127 + `<meta property="og:site_name" content="${SITE_NAME}" />`, 128 + '<meta property="og:type" content="video.other" />', 129 + `<meta property="og:title" content="${title}" />`, 130 + `<meta property="og:description" content="${description}" />`, 131 + `<meta property="og:url" content="${canonicalUrl}" />`, 132 + `<meta property="og:image" content="${imageUrl}" />`, 133 + `<meta property="og:image:secure_url" content="${imageUrl}" />`, 134 + '<meta property="og:image:type" content="image/png" />', 135 + '<meta property="og:image:width" content="1200" />', 136 + '<meta property="og:image:height" content="630" />', 137 + `<meta property="og:image:alt" content="${title}" />`, 138 + '<meta name="twitter:card" content="summary_large_image" />', 139 + `<meta name="twitter:title" content="${title}" />`, 140 + `<meta name="twitter:description" content="${description}" />`, 141 + `<meta name="twitter:image" content="${imageUrl}" />`, 142 + `<meta name="twitter:image:alt" content="${title}" />`, 143 + `<link rel="canonical" href="${canonicalUrl}" />`, 144 + ].join('\n ') 145 + } 146 + 147 + async function loadBaseHtml(context: PagesFunctionContextLike, origin: string): Promise<string> { 148 + if (!context.env.ASSETS) { 149 + return `<!doctype html><html lang="en"><head><!--OG_META_START--><!--OG_META_END--><title>${SITE_NAME}</title></head><body><div id="root"></div></body></html>` 150 + } 151 + 152 + const assetsRequest = new Request(`${origin}/index.html`, { 153 + method: 'GET', 154 + headers: { 155 + Accept: 'text/html', 156 + }, 157 + }) 158 + const response = await context.env.ASSETS.fetch(assetsRequest) 159 + if (!response.ok) { 160 + throw new Error(`Unable to load index.html (${response.status})`) 161 + } 162 + 163 + return response.text() 164 + } 165 + 166 + function injectOgMeta(html: string, metaBlock: string): string { 167 + if (OG_MARKER_PATTERN.test(html)) { 168 + return html.replace( 169 + OG_MARKER_PATTERN, 170 + `<!--OG_META_START-->\n ${metaBlock}\n <!--OG_META_END-->`, 171 + ) 172 + } 173 + 174 + if (html.includes('</head>')) { 175 + return html.replace('</head>', ` ${metaBlock}\n </head>`) 176 + } 177 + 178 + return `${html}\n${metaBlock}` 179 + } 180 + 181 + export const onRequestGet = async (context: PagesFunctionContextLike): Promise<Response> => { 182 + const url = new URL(context.request.url) 183 + 184 + const didParam = decodePathValue(context.params?.didParam) 185 + const rkeyParam = decodePathValue(context.params?.rkeyParam) 186 + 187 + if (!didParam || !rkeyParam || !didParam.startsWith('did:')) { 188 + return new Response('Invalid video URL', { 189 + status: 400, 190 + headers: { 191 + 'Content-Type': 'text/plain; charset=utf-8', 192 + }, 193 + }) 194 + } 195 + 196 + const videoUri = `at://${didParam}/${STREAMPLACE_COLLECTION}/${rkeyParam}` 197 + const metadata = await fetchTalkMetadata(videoUri, didParam, rkeyParam) 198 + const title = metadata?.title ? `${metadata.title} | ${SITE_NAME}` : `${SITE_NAME} Video` 199 + const description = metadata?.description ?? DEFAULT_DESCRIPTION 200 + const imageUrl = `${url.origin}/og-default.png` 201 + 202 + const baseHtml = await loadBaseHtml(context, url.origin) 203 + const ogMetaBlock = buildOgMetaBlock({ 204 + title, 205 + description, 206 + canonicalUrl: url.toString(), 207 + imageUrl, 208 + }) 209 + const html = injectOgMeta(baseHtml, ogMetaBlock) 210 + 211 + return new Response(html, { 212 + status: 200, 213 + headers: { 214 + 'Content-Type': 'text/html; charset=utf-8', 215 + 'Cache-Control': 'public, max-age=0, s-maxage=600', 216 + }, 217 + }) 218 + }
+21 -2
index.html
··· 16 16 /> 17 17 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 18 18 <meta name="theme-color" content="#000000" /> 19 + <!--OG_META_START--> 19 20 <meta 20 21 name="description" 21 - content="Streamplace VOD Client browses place.stream.video records across Atmosphere repos." 22 + content="Browse Streamplace and AtmosphereConf VODs with deep links and fast playback." 22 23 /> 24 + <meta property="og:site_name" content="Streamplace VOD Client" /> 25 + <meta property="og:type" content="website" /> 23 26 <meta property="og:title" content="Streamplace VOD Client" /> 24 27 <meta 25 28 property="og:description" 26 29 content="Browse Streamplace and AtmosphereConf VODs with deep links and fast playback." 27 30 /> 28 - <meta property="og:url" content="https://vods.j4ck.xyz" /> 31 + <meta property="og:url" content="https://vods.j4ck.xyz/" /> 32 + <meta property="og:image" content="https://vods.j4ck.xyz/og-default.png" /> 33 + <meta property="og:image:secure_url" content="https://vods.j4ck.xyz/og-default.png" /> 34 + <meta property="og:image:type" content="image/png" /> 35 + <meta property="og:image:width" content="1200" /> 36 + <meta property="og:image:height" content="630" /> 37 + <meta property="og:image:alt" content="Streamplace VOD Client share card" /> 38 + <meta name="twitter:card" content="summary_large_image" /> 39 + <meta name="twitter:title" content="Streamplace VOD Client" /> 40 + <meta 41 + name="twitter:description" 42 + content="Browse Streamplace and AtmosphereConf VODs with deep links and fast playback." 43 + /> 44 + <meta name="twitter:image" content="https://vods.j4ck.xyz/og-default.png" /> 45 + <meta name="twitter:image:alt" content="Streamplace VOD Client share card" /> 46 + <link rel="canonical" href="https://vods.j4ck.xyz/" /> 47 + <!--OG_META_END--> 29 48 <title>Streamplace VOD Client</title> 30 49 </head> 31 50 <body>
public/og-default.png

This is a binary file and will not be displayed.

+35
public/og-default.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc"> 2 + <title id="title">Streamplace VOD Client</title> 3 + <desc id="desc">Minimal black and white Open Graph card</desc> 4 + 5 + <rect width="1200" height="630" fill="#050505" /> 6 + 7 + <rect x="48" y="48" width="1104" height="534" fill="none" stroke="#f5f5f5" stroke-width="2" /> 8 + <rect x="72" y="72" width="1056" height="486" fill="none" stroke="#7c7c7c" stroke-width="1" /> 9 + 10 + <text x="92" y="132" fill="#f5f5f5" font-family="JetBrains Mono, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="26" letter-spacing="2"> 11 + STREAMPLACE VOD CLIENT 12 + </text> 13 + 14 + <line x1="92" y1="162" x2="1108" y2="162" stroke="#6a6a6a" stroke-width="1" /> 15 + 16 + <text x="92" y="258" fill="#ffffff" font-family="JetBrains Mono, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="72" font-weight="700" letter-spacing="1.5"> 17 + vods.j4ck.xyz 18 + </text> 19 + 20 + <text x="92" y="310" fill="#bdbdbd" font-family="JetBrains Mono, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="24"> 21 + browse + stream place.stream.video records 22 + </text> 23 + 24 + <rect x="92" y="372" width="1016" height="118" fill="#0f0f0f" stroke="#8a8a8a" stroke-width="1" /> 25 + <text x="116" y="418" fill="#ffffff" font-family="JetBrains Mono, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="22"> 26 + at://did:plc:.../place.stream.video/... 27 + </text> 28 + <text x="116" y="458" fill="#b0b0b0" font-family="JetBrains Mono, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="20"> 29 + fast playback | deep links | atmosphere archive 30 + </text> 31 + 32 + <text x="92" y="534" fill="#8e8e8e" font-family="JetBrains Mono, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="18" letter-spacing="1.2"> 33 + [OG CARD :: STATIC :: MONO :: B/W] 34 + </text> 35 + </svg>