a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social

feat: Implement client-side SEO with `react-helmet` and server-side meta tag generation for profile and review pages.

+327 -5
+163
functions/[[path]].ts
··· 1 + 2 + interface Env { 3 + ASSETS: { fetch: (request: Request) => Promise<Response> }; 4 + } 5 + 6 + export const onRequest: PagesFunction<Env> = async (context) => { 7 + const url = new URL(context.request.url); 8 + const path = url.pathname; 9 + 10 + // Only run for /profile routes 11 + // Pattern: /profile/{handle}/reviews or /profile/{handle}/review/{rkey} 12 + const profileMatch = path.match(/^\/profile\/([^/]+)(\/review\/([^/]+))?/); 13 + 14 + if (!profileMatch) { 15 + return context.env.ASSETS.fetch(context.request); 16 + } 17 + 18 + const handle = profileMatch[1]; 19 + const rkey = profileMatch[3]; // undefined if just /reviews 20 + 21 + // If it's a file request (js, css, etc), pass through 22 + if (path.includes('.')) { 23 + return context.env.ASSETS.fetch(context.request); 24 + } 25 + 26 + // 1. Fetch the original HTML 27 + const response = await context.env.ASSETS.fetch(context.request); 28 + 29 + // If not success, return original 30 + if (!response.ok) return response; 31 + 32 + // 2. Fetch Data from Bluesky Public API 33 + const publicServiceUrl = 'https://public.api.bsky.app'; 34 + 35 + let title = 'Drydown - Fragrance Reviews'; 36 + let description = 'Discover and track fragrances on the underlying social protocol.'; 37 + let image = 'https://drydown.social/default-og.png'; // Todo: Add a default image 38 + 39 + try { 40 + // Resolve Handle if needed 41 + let did = handle; 42 + if (!handle.startsWith('did:')) { 43 + const resolveRes = await fetch(`${publicServiceUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 44 + if (resolveRes.ok) { 45 + const data = await resolveRes.json() as { did: string }; 46 + did = data.did; 47 + } 48 + } 49 + 50 + if (rkey) { 51 + // --- Single Review Mode --- 52 + // Fetch Review 53 + const reviewUrl = `${publicServiceUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=social.drydown.review&rkey=${rkey}`; 54 + const reviewRes = await fetch(reviewUrl); 55 + 56 + if (reviewRes.ok) { 57 + const reviewData = await reviewRes.json() as any; 58 + const value = reviewData.value; 59 + 60 + // Fetch Fragrance Name 61 + // value.fragrance is a URI: at://did/collection/rkey 62 + // We need to parse it. 63 + if (value.fragrance) { 64 + const parts = value.fragrance.split('/'); 65 + const fDid = parts[2]; 66 + const fTo = parts[3]; 67 + const fRkey = parts[4]; 68 + 69 + const fragUrl = `${publicServiceUrl}/xrpc/com.atproto.repo.getRecord?repo=${fDid}&collection=${fTo}&rkey=${fRkey}`; 70 + const fragRes = await fetch(fragUrl); 71 + if (fragRes.ok) { 72 + const fragData = await fragRes.json() as any; 73 + title = `${fragData.value.name} Review`; 74 + 75 + // Simple description 76 + const stars = '★'.repeat(value.overallRating) + '☆'.repeat(5 - value.overallRating); 77 + const text = value.text || ''; 78 + description = `${stars} ${text.substring(0, 150)}${text.length > 150 ? '...' : ''}`; 79 + 80 + // Use fragrance image if available? Or User avatar? 81 + // For now, let's stick to default or maybe user avatar. 82 + } 83 + } 84 + 85 + // Fetch Profile for Author Name 86 + const profileUrl = `${publicServiceUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`; 87 + const profileRes = await fetch(profileUrl); 88 + if (profileRes.ok) { 89 + const profile = await profileRes.json() as any; 90 + title = `${title} by ${profile.displayName || profile.handle}`; 91 + if (profile.avatar) { 92 + // image = profile.avatar; // Avatar might be too small/square for OG Image, but better than nothing? 93 + // Twitter summary card works well with square. 94 + // For large_image, maybe not. 95 + } 96 + } 97 + } 98 + 99 + } else { 100 + // --- Profile Mode --- 101 + const profileUrl = `${publicServiceUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`; 102 + const profileRes = await fetch(profileUrl); 103 + 104 + if (profileRes.ok) { 105 + const profile = await profileRes.json() as any; 106 + title = `${profile.displayName || profile.handle} (@${profile.handle})`; 107 + description = `Check out fragrance reviews by ${profile.displayName || profile.handle} on Drydown.`; 108 + if (profile.avatar) { 109 + image = profile.avatar; 110 + } 111 + } 112 + } 113 + } catch (e) { 114 + console.error("SEO Injection Failed", e); 115 + // Fallback to default 116 + } 117 + 118 + // 3. Rewrite HTML 119 + // We use HTMLRewriter to inject tags 120 + const rewriter = new HTMLRewriter() 121 + .on('head', { 122 + element(element) { 123 + // Clean existing generic tags if we want, or just append. 124 + // Helmet usually overwrites/manages in client, but for raw HTML we just append. 125 + // Actually, appending might duplicate if index.html has defaults. 126 + // index.html usually has minimal tags. 127 + 128 + const tags = ` 129 + <meta property="og:title" content="${title.replace(/"/g, '&quot;')}" /> 130 + <meta property="og:description" content="${description.replace(/"/g, '&quot;')}" /> 131 + <meta property="og:image" content="${image}" /> 132 + <meta property="twitter:card" content="summary" /> 133 + <meta property="twitter:title" content="${title.replace(/"/g, '&quot;')}" /> 134 + <meta property="twitter:description" content="${description.replace(/"/g, '&quot;')}" /> 135 + <meta property="twitter:image" content="${image}" /> 136 + `; 137 + element.append(tags, { html: true }); 138 + } 139 + }) 140 + .on('title', { 141 + element(element) { 142 + element.setInnerContent(title); 143 + } 144 + }); 145 + 146 + const transformedResponse = rewriter.transform(response); 147 + 148 + // Clone the response to make headers mutable if needed, or just try setting them. 149 + // Cloudflare Pages functions often allow setting headers on the response object returned. 150 + // But standard Fetch Response headers are read-only-ish. 151 + // Best practice: Create a new Response with the transformed body and new headers. 152 + 153 + const newHeaders = new Headers(transformedResponse.headers); 154 + // Cache for 1 hour at edge and browser. 155 + // aggressive: s-maxage (CDN) = 1 hour, max-age (Browser) = 1 hour 156 + newHeaders.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); 157 + 158 + return new Response(transformedResponse.body, { 159 + status: transformedResponse.status, 160 + statusText: transformedResponse.statusText, 161 + headers: newHeaders 162 + }); 163 + }
+100 -4
package-lock.json
··· 11 11 "@atproto/api": "^0.18.13", 12 12 "@atproto/oauth-client-browser": "^0.3.39", 13 13 "@taurean/stylebase": "^0.11.0", 14 + "@types/react-helmet": "^6.1.11", 14 15 "idb-keyval": "^6.2.2", 15 16 "preact": "^10.27.2", 17 + "react-helmet": "^6.1.0", 16 18 "wouter": "^3.9.0" 17 19 }, 18 20 "devDependencies": { ··· 1556 1558 "undici-types": "~7.16.0" 1557 1559 } 1558 1560 }, 1561 + "node_modules/@types/react": { 1562 + "version": "19.2.14", 1563 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", 1564 + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", 1565 + "license": "MIT", 1566 + "dependencies": { 1567 + "csstype": "^3.2.2" 1568 + } 1569 + }, 1570 + "node_modules/@types/react-helmet": { 1571 + "version": "6.1.11", 1572 + "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.11.tgz", 1573 + "integrity": "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==", 1574 + "license": "MIT", 1575 + "dependencies": { 1576 + "@types/react": "*" 1577 + } 1578 + }, 1559 1579 "node_modules/ansi-styles": { 1560 1580 "version": "4.3.0", 1561 1581 "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", ··· 1779 1799 "url": "https://github.com/sponsors/fb55" 1780 1800 } 1781 1801 }, 1802 + "node_modules/csstype": { 1803 + "version": "3.2.3", 1804 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", 1805 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", 1806 + "license": "MIT" 1807 + }, 1782 1808 "node_modules/debug": { 1783 1809 "version": "4.4.3", 1784 1810 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", ··· 2005 2031 "version": "4.0.0", 2006 2032 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 2007 2033 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 2008 - "dev": true, 2009 2034 "license": "MIT" 2010 2035 }, 2011 2036 "node_modules/jsesc": { ··· 2041 2066 "dev": true, 2042 2067 "license": "MIT" 2043 2068 }, 2069 + "node_modules/loose-envify": { 2070 + "version": "1.4.0", 2071 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 2072 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 2073 + "license": "MIT", 2074 + "dependencies": { 2075 + "js-tokens": "^3.0.0 || ^4.0.0" 2076 + }, 2077 + "bin": { 2078 + "loose-envify": "cli.js" 2079 + } 2080 + }, 2044 2081 "node_modules/lru-cache": { 2045 2082 "version": "5.1.1", 2046 2083 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", ··· 2146 2183 "url": "https://github.com/fb55/nth-check?sponsor=1" 2147 2184 } 2148 2185 }, 2186 + "node_modules/object-assign": { 2187 + "version": "4.1.1", 2188 + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 2189 + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", 2190 + "license": "MIT", 2191 + "engines": { 2192 + "node": ">=0.10.0" 2193 + } 2194 + }, 2149 2195 "node_modules/path-browserify": { 2150 2196 "version": "1.0.1", 2151 2197 "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", ··· 2228 2274 "url": "https://github.com/prettier/prettier?sponsor=1" 2229 2275 } 2230 2276 }, 2277 + "node_modules/prop-types": { 2278 + "version": "15.8.1", 2279 + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", 2280 + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", 2281 + "license": "MIT", 2282 + "dependencies": { 2283 + "loose-envify": "^1.4.0", 2284 + "object-assign": "^4.1.1", 2285 + "react-is": "^16.13.1" 2286 + } 2287 + }, 2231 2288 "node_modules/react": { 2232 - "version": "19.2.4", 2233 - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", 2234 - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", 2289 + "version": "18.3.1", 2290 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 2291 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 2235 2292 "license": "MIT", 2236 2293 "peer": true, 2294 + "dependencies": { 2295 + "loose-envify": "^1.1.0" 2296 + }, 2237 2297 "engines": { 2238 2298 "node": ">=0.10.0" 2299 + } 2300 + }, 2301 + "node_modules/react-fast-compare": { 2302 + "version": "3.2.2", 2303 + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", 2304 + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", 2305 + "license": "MIT" 2306 + }, 2307 + "node_modules/react-helmet": { 2308 + "version": "6.1.0", 2309 + "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", 2310 + "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==", 2311 + "license": "MIT", 2312 + "dependencies": { 2313 + "object-assign": "^4.1.1", 2314 + "prop-types": "^15.7.2", 2315 + "react-fast-compare": "^3.1.1", 2316 + "react-side-effect": "^2.1.0" 2317 + }, 2318 + "peerDependencies": { 2319 + "react": ">=16.3.0" 2320 + } 2321 + }, 2322 + "node_modules/react-is": { 2323 + "version": "16.13.1", 2324 + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", 2325 + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", 2326 + "license": "MIT" 2327 + }, 2328 + "node_modules/react-side-effect": { 2329 + "version": "2.1.2", 2330 + "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", 2331 + "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", 2332 + "license": "MIT", 2333 + "peerDependencies": { 2334 + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" 2239 2335 } 2240 2336 }, 2241 2337 "node_modules/regexparam": {
+2
package.json
··· 13 13 "@atproto/api": "^0.18.13", 14 14 "@atproto/oauth-client-browser": "^0.3.39", 15 15 "@taurean/stylebase": "^0.11.0", 16 + "@types/react-helmet": "^6.1.11", 16 17 "idb-keyval": "^6.2.2", 17 18 "preact": "^10.27.2", 19 + "react-helmet": "^6.1.0", 18 20 "wouter": "^3.9.0" 19 21 }, 20 22 "devDependencies": {
+9
src/components/ProfilePage.tsx
··· 2 2 import { useState, useEffect } from 'preact/hooks' 3 3 import { useLocation } from 'wouter' 4 4 import { AtpBaseClient } from '../client/index' 5 + import { SEO } from './SEO' 5 6 import { ReviewList } from './ReviewList' 6 7 7 8 interface ProfilePageProps { ··· 17 18 const [error, setError] = useState<string | null>(null) 18 19 19 20 useEffect(() => { 21 + // ... (existing code handles data loading) 22 + 20 23 async function loadProfileAndReviews() { 21 24 try { 22 25 setIsLoading(true) ··· 151 154 152 155 return ( 153 156 <div class="profile-page page-container"> 157 + <SEO 158 + title={`${profile.displayName || profile.handle} (@${profile.handle}) - Drydown`} 159 + description={`Read ${reviews.length} fragrance reviews by ${profile.displayName || profile.handle} on Drydown.`} 160 + url={window.location.href} 161 + /> 154 162 <nav style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', borderBottom: '1px solid #eee', paddingBottom: '1rem' }}> 163 + 155 164 <a href="/" style={{ textDecoration: 'none', color: '#000', fontWeight: '800', fontSize: '1.5rem' }}>Drydown</a> 156 165 </nav> 157 166
+39
src/components/SEO.tsx
··· 1 + 2 + import { Helmet } from 'react-helmet'; 3 + 4 + interface SEOProps { 5 + title: string; 6 + description: string; 7 + image?: string; 8 + type?: string; 9 + url?: string; 10 + } 11 + 12 + export function SEO({ title, description, image, type = 'website', url }: SEOProps) { 13 + const siteUrl = window.location.origin; 14 + const currentUrl = url || window.location.href; 15 + const defaultImage = `${siteUrl}/default-og.png`; // Placeholder, ideally use a real default image 16 + const metaImage = image || defaultImage; 17 + 18 + return ( 19 + <Helmet> 20 + {/* Standard Metadata */} 21 + <title>{title}</title> 22 + <meta name="description" content={description} /> 23 + 24 + {/* Open Graph / Facebook */} 25 + <meta property="og:type" content={type} /> 26 + <meta property="og:url" content={currentUrl} /> 27 + <meta property="og:title" content={title} /> 28 + <meta property="og:description" content={description} /> 29 + <meta property="og:image" content={metaImage} /> 30 + 31 + {/* Twitter */} 32 + <meta property="twitter:card" content="summary_large_image" /> 33 + <meta property="twitter:url" content={currentUrl} /> 34 + <meta property="twitter:title" content={title} /> 35 + <meta property="twitter:description" content={description} /> 36 + <meta property="twitter:image" content={metaImage} /> 37 + </Helmet> 38 + ); 39 + }
+11
src/components/SingleReviewPage.tsx
··· 1 1 2 2 import { useState, useEffect } from 'preact/hooks' 3 3 import { AtpBaseClient } from '../client/index' 4 + import { SEO } from '../components/SEO' 4 5 5 6 interface SingleReviewPageProps { 6 7 handle: string ··· 272 273 ) 273 274 if (!review) return <div class="container">Review not found</div> 274 275 276 + 277 + const fragName = fragrance?.name || 'Unknown Fragrance' 278 + const stars = '★'.repeat(review.overallRating) + '☆'.repeat(5 - review.overallRating) 279 + const metaDescription = `${stars} ${review.text ? review.text.slice(0, 150) + (review.text.length > 150 ? '...' : '') : 'Read this review on Drydown.'}` 280 + 275 281 return ( 276 282 <div class="single-review-page page-container"> 283 + <SEO 284 + title={`${fragName} Review by ${profile?.displayName || handle}`} 285 + description={metaDescription} 286 + url={window.location.href} 287 + /> 277 288 <nav style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', borderBottom: '1px solid #eee', paddingBottom: '1rem' }}> 278 289 <a href="/" style={{ textDecoration: 'none', color: '#000', fontWeight: '800', fontSize: '1.5rem' }}>Drydown</a> 279 290 </nav>
+3 -1
vite.config.ts
··· 6 6 plugins: [preact()], 7 7 resolve: { 8 8 alias: { 9 - '@': path.resolve(__dirname, './src') 9 + '@': path.resolve(__dirname, './src'), 10 + 'react': 'preact/compat', 11 + 'react-dom': 'preact/compat', 10 12 } 11 13 }, 12 14 server: {