See the best posts from any Bluesky account
0
fork

Configure Feed

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

Add dynamic OpenGraph images for landing and profile pages

Generate OG images on-the-fly using Satori and @resvg/resvg-js with
dark theme branding (heart logo + Gabarito/Figtree fonts). Cached via
Cache-Control headers (1 day). Adds og:image, og:title, og:description,
and twitter:card meta tags to landing and profile templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+630 -2
+1
.adonisjs/server/controllers.ts
··· 8 8 HealthChecks: () => import('#controllers/health_checks_controller'), 9 9 Landing: () => import('#controllers/landing_controller'), 10 10 Oauth: () => import('#controllers/oauth_controller'), 11 + OgImage: () => import('#controllers/og_image_controller'), 11 12 Profile: () => import('#controllers/profile_controller'), 12 13 Search: () => import('#controllers/search_controller'), 13 14 Typeahead: () => import('#controllers/typeahead_controller'),
+6
.adonisjs/server/routes.d.ts
··· 20 20 'api.like.delete': { paramsTuple?: []; params?: {} } 21 21 'api.repost.create': { paramsTuple?: []; params?: {} } 22 22 'api.repost.delete': { paramsTuple?: []; params?: {} } 23 + 'og.landing': { paramsTuple?: []; params?: {} } 24 + 'og.profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 23 25 'health.live': { paramsTuple?: []; params?: {} } 24 26 'health.ready': { paramsTuple?: []; params?: {} } 25 27 } ··· 35 37 'oauth.clientMetadata': { paramsTuple?: []; params?: {} } 36 38 'oauth.login': { paramsTuple?: []; params?: {} } 37 39 'oauth.callback': { paramsTuple?: []; params?: {} } 40 + 'og.landing': { paramsTuple?: []; params?: {} } 41 + 'og.profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 38 42 'health.live': { paramsTuple?: []; params?: {} } 39 43 'health.ready': { paramsTuple?: []; params?: {} } 40 44 } ··· 50 54 'oauth.clientMetadata': { paramsTuple?: []; params?: {} } 51 55 'oauth.login': { paramsTuple?: []; params?: {} } 52 56 'oauth.callback': { paramsTuple?: []; params?: {} } 57 + 'og.landing': { paramsTuple?: []; params?: {} } 58 + 'og.profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 53 59 'health.live': { paramsTuple?: []; params?: {} } 54 60 'health.ready': { paramsTuple?: []; params?: {} } 55 61 }
+15 -2
app/controllers/landing_controller.ts
··· 1 1 import type { HttpContext } from '@adonisjs/core/http' 2 + import env from '#start/env' 3 + import TrackedProfile from '#models/tracked_profile' 2 4 3 - const EXAMPLE_HANDLES = ['dril.bsky.social', 'kumail.bsky.social', 'petridishes.bsky.social'] 5 + const FALLBACK_HANDLES = ['dril.bsky.social', 'kumail.bsky.social', 'petridishes.bsky.social'] 4 6 5 7 export default class LandingController { 6 8 async show({ view }: HttpContext) { 7 - return view.render('pages/landing', { examples: EXAMPLE_HANDLES }) 9 + const featured = await TrackedProfile.query() 10 + .where('is_featured', true) 11 + .orderByRaw('RANDOM()') 12 + .limit(3) 13 + 14 + const examples = 15 + featured.length > 0 ? featured.map((p) => p.handle) : FALLBACK_HANDLES 16 + 17 + return view.render('pages/landing', { 18 + examples, 19 + appUrl: env.get('APP_URL'), 20 + }) 8 21 } 9 22 }
+21
app/controllers/og_image_controller.ts
··· 1 + import type { HttpContext } from '@adonisjs/core/http' 2 + import { renderLandingOgImage, renderProfileOgImage } from '#lib/og/renderer' 3 + 4 + const ONE_DAY = 86_400 5 + 6 + export default class OgImageController { 7 + async landing({ response }: HttpContext) { 8 + const png = await renderLandingOgImage() 9 + response.header('Content-Type', 'image/png') 10 + response.header('Cache-Control', `public, max-age=${ONE_DAY}, s-maxage=${ONE_DAY}`) 11 + return response.send(png) 12 + } 13 + 14 + async profile({ params, response }: HttpContext) { 15 + const handle = params.handle as string 16 + const png = await renderProfileOgImage({ handle }) 17 + response.header('Content-Type', 'image/png') 18 + response.header('Cache-Control', `public, max-age=${ONE_DAY}, s-maxage=${ONE_DAY}`) 19 + return response.send(png) 20 + } 21 + }
+9
app/controllers/profile_controller.ts
··· 358 358 const daysQs = daysWindow ? `?days=${daysWindow}` : '' 359 359 const canonicalUrl = `${appUrl}/profile/${canonicalHandle}/${kind}${daysQs}` 360 360 361 + const kindLabel = kind === 'likes' ? 'liked' : 'reposted' 362 + const nameForOg = profile.displayName || `@${canonicalHandle}` 363 + const ogTitle = `${nameForOg}'s top ${kindLabel} posts` 364 + const ogDescription = `See the most popular posts from @${canonicalHandle} on Bluesky.` 365 + const ogImageUrl = `${appUrl}/og/profile/${canonicalHandle}.png` 366 + 361 367 response.header('Cache-Control', isAuthenticated ? 'private, no-store' : 'public, max-age=60') 362 368 response.header('Vary', 'Cookie') 363 369 return view.render('pages/profile/show', { ··· 368 374 daysWindow, 369 375 posts: postsWithUrl, 370 376 canonicalUrl, 377 + ogTitle, 378 + ogDescription, 379 + ogImageUrl, 371 380 indexedSince, 372 381 viewer, 373 382 })
+202
app/lib/og/renderer.ts
··· 1 + import satori from 'satori' 2 + import { Resvg } from '@resvg/resvg-js' 3 + import { readFileSync } from 'node:fs' 4 + import { join } from 'node:path' 5 + import app from '@adonisjs/core/services/app' 6 + 7 + // --------------------------------------------------------------------------- 8 + // Colors (hex approximations of the oklch values in app.css) 9 + // --------------------------------------------------------------------------- 10 + 11 + const COLORS = { 12 + gray950: '#111827', 13 + gray100: '#f0f1f5', 14 + gray400: '#9ba3b0', 15 + red500: '#e5484d', 16 + } 17 + 18 + // --------------------------------------------------------------------------- 19 + // Fonts (loaded once, lazily) 20 + // --------------------------------------------------------------------------- 21 + 22 + let fontsLoaded: { name: string; data: ArrayBuffer; weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; style: 'normal' }[] | null = null 23 + 24 + function getFonts() { 25 + if (fontsLoaded) return fontsLoaded 26 + 27 + const fontsDir = join(app.makePath(), 'resources', 'fonts') 28 + 29 + fontsLoaded = [ 30 + { 31 + name: 'Figtree', 32 + data: readFileSync(join(fontsDir, 'figtree-400.woff')).buffer as ArrayBuffer, 33 + weight: 400 as const, 34 + style: 'normal' as const, 35 + }, 36 + { 37 + name: 'Figtree', 38 + data: readFileSync(join(fontsDir, 'figtree-700.woff')).buffer as ArrayBuffer, 39 + weight: 700 as const, 40 + style: 'normal' as const, 41 + }, 42 + { 43 + name: 'Gabarito', 44 + data: readFileSync(join(fontsDir, 'gabarito-700.woff')).buffer as ArrayBuffer, 45 + weight: 700 as const, 46 + style: 'normal' as const, 47 + }, 48 + ] 49 + return fontsLoaded 50 + } 51 + 52 + // --------------------------------------------------------------------------- 53 + // Shared layout wrapper 54 + // --------------------------------------------------------------------------- 55 + 56 + const WIDTH = 1200 57 + const HEIGHT = 630 58 + 59 + function wrapLayout(children: Record<string, unknown> | Record<string, unknown>[]) { 60 + return { 61 + type: 'div', 62 + props: { 63 + style: { 64 + width: '100%', 65 + height: '100%', 66 + display: 'flex', 67 + flexDirection: 'column', 68 + justifyContent: 'center', 69 + alignItems: 'center', 70 + backgroundColor: COLORS.gray950, 71 + padding: '60px', 72 + }, 73 + children, 74 + }, 75 + } 76 + } 77 + 78 + // --------------------------------------------------------------------------- 79 + // Heart + "favs.blue" logo element 80 + // --------------------------------------------------------------------------- 81 + 82 + function logoElement(fontSize: number = 48) { 83 + return { 84 + type: 'div', 85 + props: { 86 + style: { 87 + display: 'flex', 88 + alignItems: 'center', 89 + gap: '12px', 90 + }, 91 + children: [ 92 + { 93 + type: 'svg', 94 + props: { 95 + viewBox: '0 0 24 24', 96 + fill: COLORS.red500, 97 + width: `${fontSize}px`, 98 + height: `${fontSize}px`, 99 + children: { 100 + type: 'path', 101 + props: { 102 + d: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z', 103 + }, 104 + }, 105 + }, 106 + }, 107 + { 108 + type: 'div', 109 + props: { 110 + style: { 111 + fontFamily: 'Gabarito', 112 + fontWeight: 700, 113 + fontSize: `${fontSize}px`, 114 + color: COLORS.gray100, 115 + lineHeight: 1, 116 + }, 117 + children: 'favs.blue', 118 + }, 119 + }, 120 + ], 121 + }, 122 + } 123 + } 124 + 125 + // --------------------------------------------------------------------------- 126 + // Render helpers 127 + // --------------------------------------------------------------------------- 128 + 129 + async function renderToBuffer(markup: Record<string, unknown>): Promise<Buffer> { 130 + const svg = await satori(markup as Parameters<typeof satori>[0], { 131 + width: WIDTH, 132 + height: HEIGHT, 133 + fonts: getFonts(), 134 + }) 135 + const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } }) 136 + const pngData = resvg.render() 137 + return Buffer.from(pngData.asPng()) 138 + } 139 + 140 + // --------------------------------------------------------------------------- 141 + // Public API 142 + // --------------------------------------------------------------------------- 143 + 144 + export async function renderLandingOgImage(): Promise<Buffer> { 145 + const markup = wrapLayout([ 146 + logoElement(72), 147 + { 148 + type: 'div', 149 + props: { 150 + style: { 151 + fontFamily: 'Figtree', 152 + fontWeight: 400, 153 + fontSize: '32px', 154 + color: COLORS.gray400, 155 + marginTop: '24px', 156 + }, 157 + children: "See any Bluesky account's most popular posts.", 158 + }, 159 + }, 160 + ]) 161 + return renderToBuffer(markup) 162 + } 163 + 164 + export async function renderProfileOgImage(opts: { 165 + handle: string 166 + displayName?: string | null 167 + }): Promise<Buffer> { 168 + const title = opts.displayName 169 + ? `${opts.displayName}'s top posts` 170 + : `@${opts.handle}'s top posts` 171 + 172 + const markup = wrapLayout([ 173 + { 174 + type: 'div', 175 + props: { 176 + style: { 177 + fontFamily: 'Gabarito', 178 + fontWeight: 700, 179 + fontSize: '56px', 180 + color: COLORS.gray100, 181 + textAlign: 'center', 182 + lineHeight: 1.2, 183 + maxWidth: '1000px', 184 + }, 185 + children: title, 186 + }, 187 + }, 188 + { 189 + type: 'div', 190 + props: { 191 + style: { 192 + display: 'flex', 193 + alignItems: 'center', 194 + gap: '12px', 195 + marginTop: '40px', 196 + }, 197 + children: [logoElement(36)], 198 + }, 199 + }, 200 + ]) 201 + return renderToBuffer(markup) 202 + }
+2
package.json
··· 86 86 "@atproto/api": "^0.19.8", 87 87 "@atproto/oauth-client-node": "^0.3.17", 88 88 "@clickhouse/client": "^1.18.2", 89 + "@resvg/resvg-js": "^2.6.2", 89 90 "@tailwindcss/vite": "^4.2.2", 90 91 "@vinejs/vine": "^4.3.0", 91 92 "better-sqlite3": "^12.8.0", ··· 94 95 "posthog-js": "^1.367.0", 95 96 "posthog-node": "^5.29.2", 96 97 "reflect-metadata": "^0.2.2", 98 + "satori": "^0.26.0", 97 99 "tailwindcss": "^4.2.2" 98 100 }, 99 101 "hotHook": {
+273
pnpm-lock.yaml
··· 50 50 '@clickhouse/client': 51 51 specifier: ^1.18.2 52 52 version: 1.18.2 53 + '@resvg/resvg-js': 54 + specifier: ^2.6.2 55 + version: 2.6.2 53 56 '@tailwindcss/vite': 54 57 specifier: ^4.2.2 55 58 version: 4.2.2(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) ··· 74 77 reflect-metadata: 75 78 specifier: ^0.2.2 76 79 version: 0.2.2 80 + satori: 81 + specifier: ^0.26.0 82 + version: 0.26.0 77 83 tailwindcss: 78 84 specifier: ^4.2.2 79 85 version: 4.2.2 ··· 1672 1678 '@protobufjs/utf8@1.1.0': 1673 1679 resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} 1674 1680 1681 + '@resvg/resvg-js-android-arm-eabi@2.6.2': 1682 + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} 1683 + engines: {node: '>= 10'} 1684 + cpu: [arm] 1685 + os: [android] 1686 + 1687 + '@resvg/resvg-js-android-arm64@2.6.2': 1688 + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} 1689 + engines: {node: '>= 10'} 1690 + cpu: [arm64] 1691 + os: [android] 1692 + 1693 + '@resvg/resvg-js-darwin-arm64@2.6.2': 1694 + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} 1695 + engines: {node: '>= 10'} 1696 + cpu: [arm64] 1697 + os: [darwin] 1698 + 1699 + '@resvg/resvg-js-darwin-x64@2.6.2': 1700 + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} 1701 + engines: {node: '>= 10'} 1702 + cpu: [x64] 1703 + os: [darwin] 1704 + 1705 + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': 1706 + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} 1707 + engines: {node: '>= 10'} 1708 + cpu: [arm] 1709 + os: [linux] 1710 + 1711 + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': 1712 + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} 1713 + engines: {node: '>= 10'} 1714 + cpu: [arm64] 1715 + os: [linux] 1716 + libc: [glibc] 1717 + 1718 + '@resvg/resvg-js-linux-arm64-musl@2.6.2': 1719 + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} 1720 + engines: {node: '>= 10'} 1721 + cpu: [arm64] 1722 + os: [linux] 1723 + libc: [musl] 1724 + 1725 + '@resvg/resvg-js-linux-x64-gnu@2.6.2': 1726 + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} 1727 + engines: {node: '>= 10'} 1728 + cpu: [x64] 1729 + os: [linux] 1730 + libc: [glibc] 1731 + 1732 + '@resvg/resvg-js-linux-x64-musl@2.6.2': 1733 + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} 1734 + engines: {node: '>= 10'} 1735 + cpu: [x64] 1736 + os: [linux] 1737 + libc: [musl] 1738 + 1739 + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': 1740 + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} 1741 + engines: {node: '>= 10'} 1742 + cpu: [arm64] 1743 + os: [win32] 1744 + 1745 + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': 1746 + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} 1747 + engines: {node: '>= 10'} 1748 + cpu: [ia32] 1749 + os: [win32] 1750 + 1751 + '@resvg/resvg-js-win32-x64-msvc@2.6.2': 1752 + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} 1753 + engines: {node: '>= 10'} 1754 + cpu: [x64] 1755 + os: [win32] 1756 + 1757 + '@resvg/resvg-js@2.6.2': 1758 + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} 1759 + engines: {node: '>= 10'} 1760 + 1675 1761 '@rollup/rollup-android-arm-eabi@4.60.1': 1676 1762 resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} 1677 1763 cpu: [arm] ··· 1812 1898 1813 1899 '@sec-ant/readable-stream@0.4.1': 1814 1900 resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} 1901 + 1902 + '@shuding/opentype.js@1.4.0-beta.0': 1903 + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} 1904 + engines: {node: '>= 8.0.0'} 1905 + hasBin: true 1815 1906 1816 1907 '@sinclair/typebox@0.34.49': 1817 1908 resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} ··· 2273 2364 resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} 2274 2365 engines: {node: 18 || 20 || >=22} 2275 2366 2367 + base64-js@0.0.8: 2368 + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} 2369 + engines: {node: '>= 0.4'} 2370 + 2276 2371 base64-js@1.5.1: 2277 2372 resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 2278 2373 ··· 2357 2452 camelcase@9.0.0: 2358 2453 resolution: {integrity: sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw==} 2359 2454 engines: {node: '>=20'} 2455 + 2456 + camelize@1.0.1: 2457 + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} 2360 2458 2361 2459 caniuse-lite@1.0.30001787: 2362 2460 resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} ··· 2491 2589 resolution: {integrity: sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==} 2492 2590 engines: {node: '>= 0.8'} 2493 2591 2592 + css-background-parser@0.1.0: 2593 + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} 2594 + 2595 + css-box-shadow@1.0.0-3: 2596 + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} 2597 + 2598 + css-color-keywords@1.0.0: 2599 + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} 2600 + engines: {node: '>=4'} 2601 + 2602 + css-gradient-parser@0.0.17: 2603 + resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==} 2604 + engines: {node: '>=16'} 2605 + 2606 + css-to-react-native@3.2.0: 2607 + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} 2608 + 2494 2609 data-uri-to-buffer@4.0.1: 2495 2610 resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} 2496 2611 engines: {node: '>= 12'} ··· 2611 2726 emittery@1.2.1: 2612 2727 resolution: {integrity: sha512-sFz64DCRjirhwHLxofFqxYQm6DCp6o0Ix7jwKQvuCHPn4GMRZNuBZyLPu9Ccmk/QSCAMZt6FOUqA8JZCQvA9fw==} 2613 2728 engines: {node: '>=14.16'} 2729 + 2730 + emoji-regex-xs@2.0.1: 2731 + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} 2732 + engines: {node: '>=10.0.0'} 2614 2733 2615 2734 emoji-regex@8.0.0: 2616 2735 resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} ··· 2827 2946 fflate@0.4.8: 2828 2947 resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} 2829 2948 2949 + fflate@0.7.4: 2950 + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} 2951 + 2830 2952 figures@6.1.0: 2831 2953 resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} 2832 2954 engines: {node: '>=18'} ··· 3002 3124 3003 3125 help-me@5.0.0: 3004 3126 resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} 3127 + 3128 + hex-rgb@4.3.0: 3129 + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} 3130 + engines: {node: '>=6'} 3005 3131 3006 3132 hosted-git-info@9.0.2: 3007 3133 resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} ··· 3341 3467 lightningcss@1.32.0: 3342 3468 resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} 3343 3469 engines: {node: '>= 12.0.0'} 3470 + 3471 + linebreak@1.1.0: 3472 + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} 3344 3473 3345 3474 locate-path@6.0.0: 3346 3475 resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} ··· 3532 3661 package-manager-detector@1.6.0: 3533 3662 resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} 3534 3663 3664 + pako@0.2.9: 3665 + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} 3666 + 3667 + parse-css-color@0.2.1: 3668 + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} 3669 + 3535 3670 parse-imports@3.0.0: 3536 3671 resolution: {integrity: sha512-IwiqoJANa4O6M76LBWEvoS2iPIUqBOnKG1lV3/J0oVM6V2XjED+mYAXedEMX5xUglVjfGpZOfaEyuOUjBuUE4g==} 3537 3672 engines: {node: '>= 22'} ··· 3623 3758 resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} 3624 3759 engines: {node: '>=4'} 3625 3760 3761 + postcss-value-parser@4.2.0: 3762 + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} 3763 + 3626 3764 postcss@8.5.9: 3627 3765 resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} 3628 3766 engines: {node: ^10 || ^12 || >=14} ··· 3847 3985 safer-buffer@2.1.2: 3848 3986 resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 3849 3987 3988 + satori@0.26.0: 3989 + resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==} 3990 + engines: {node: '>=16'} 3991 + 3850 3992 secure-json-parse@4.1.0: 3851 3993 resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} 3852 3994 ··· 3961 4103 resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} 3962 4104 engines: {node: '>=20'} 3963 4105 4106 + string.prototype.codepointat@0.2.1: 4107 + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} 4108 + 3964 4109 string_decoder@1.3.0: 3965 4110 resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 3966 4111 ··· 4061 4206 timekeeper@2.3.1: 4062 4207 resolution: {integrity: sha512-LeQRS7/4JcC0PgdSFnfUiStQEdiuySlCj/5SJ18D+T1n9BoY7PxKFfCwLulpHXoLUFr67HxBddQdEX47lDGx1g==} 4063 4208 4209 + tiny-inflate@1.0.3: 4210 + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} 4211 + 4064 4212 tinyexec@1.1.1: 4065 4213 resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} 4066 4214 engines: {node: '>=18'} ··· 4165 4313 4166 4314 unicode-segmenter@0.14.5: 4167 4315 resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 4316 + 4317 + unicode-trie@2.0.0: 4318 + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} 4168 4319 4169 4320 unicorn-magic@0.3.0: 4170 4321 resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} ··· 4309 4460 yoctocolors@2.1.2: 4310 4461 resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} 4311 4462 engines: {node: '>=18'} 4463 + 4464 + yoga-layout@3.2.1: 4465 + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} 4312 4466 4313 4467 youch-core@0.3.3: 4314 4468 resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} ··· 6093 6247 '@protobufjs/pool@1.1.0': {} 6094 6248 6095 6249 '@protobufjs/utf8@1.1.0': {} 6250 + 6251 + '@resvg/resvg-js-android-arm-eabi@2.6.2': 6252 + optional: true 6253 + 6254 + '@resvg/resvg-js-android-arm64@2.6.2': 6255 + optional: true 6256 + 6257 + '@resvg/resvg-js-darwin-arm64@2.6.2': 6258 + optional: true 6259 + 6260 + '@resvg/resvg-js-darwin-x64@2.6.2': 6261 + optional: true 6262 + 6263 + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': 6264 + optional: true 6265 + 6266 + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': 6267 + optional: true 6268 + 6269 + '@resvg/resvg-js-linux-arm64-musl@2.6.2': 6270 + optional: true 6271 + 6272 + '@resvg/resvg-js-linux-x64-gnu@2.6.2': 6273 + optional: true 6274 + 6275 + '@resvg/resvg-js-linux-x64-musl@2.6.2': 6276 + optional: true 6277 + 6278 + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': 6279 + optional: true 6280 + 6281 + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': 6282 + optional: true 6283 + 6284 + '@resvg/resvg-js-win32-x64-msvc@2.6.2': 6285 + optional: true 6286 + 6287 + '@resvg/resvg-js@2.6.2': 6288 + optionalDependencies: 6289 + '@resvg/resvg-js-android-arm-eabi': 2.6.2 6290 + '@resvg/resvg-js-android-arm64': 2.6.2 6291 + '@resvg/resvg-js-darwin-arm64': 2.6.2 6292 + '@resvg/resvg-js-darwin-x64': 2.6.2 6293 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 6294 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 6295 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 6296 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 6297 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 6298 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 6299 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 6300 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 6096 6301 6097 6302 '@rollup/rollup-android-arm-eabi@4.60.1': 6098 6303 optional: true ··· 6171 6376 6172 6377 '@sec-ant/readable-stream@0.4.1': {} 6173 6378 6379 + '@shuding/opentype.js@1.4.0-beta.0': 6380 + dependencies: 6381 + fflate: 0.7.4 6382 + string.prototype.codepointat: 0.2.1 6383 + 6174 6384 '@sinclair/typebox@0.34.49': {} 6175 6385 6176 6386 '@sindresorhus/is@7.2.0': {} ··· 6593 6803 6594 6804 balanced-match@4.0.4: {} 6595 6805 6806 + base64-js@0.0.8: {} 6807 + 6596 6808 base64-js@1.5.1: {} 6597 6809 6598 6810 baseline-browser-mapping@2.10.17: {} ··· 6669 6881 get-intrinsic: 1.3.0 6670 6882 6671 6883 camelcase@9.0.0: {} 6884 + 6885 + camelize@1.0.1: {} 6672 6886 6673 6887 caniuse-lite@1.0.30001787: {} 6674 6888 ··· 6794 7008 tsscmp: 1.0.6 6795 7009 uid-safe: 2.1.5 6796 7010 7011 + css-background-parser@0.1.0: {} 7012 + 7013 + css-box-shadow@1.0.0-3: {} 7014 + 7015 + css-color-keywords@1.0.0: {} 7016 + 7017 + css-gradient-parser@0.0.17: {} 7018 + 7019 + css-to-react-native@3.2.0: 7020 + dependencies: 7021 + camelize: 1.0.1 7022 + css-color-keywords: 1.0.0 7023 + postcss-value-parser: 4.2.0 7024 + 6797 7025 data-uri-to-buffer@4.0.1: {} 6798 7026 6799 7027 dateformat@4.6.3: {} ··· 6889 7117 electron-to-chromium@1.5.334: {} 6890 7118 6891 7119 emittery@1.2.1: {} 7120 + 7121 + emoji-regex-xs@2.0.1: {} 6892 7122 6893 7123 emoji-regex@8.0.0: {} 6894 7124 ··· 7139 7369 7140 7370 fflate@0.4.8: {} 7141 7371 7372 + fflate@0.7.4: {} 7373 + 7142 7374 figures@6.1.0: 7143 7375 dependencies: 7144 7376 is-unicode-supported: 2.1.0 ··· 7305 7537 he@1.2.0: {} 7306 7538 7307 7539 help-me@5.0.0: {} 7540 + 7541 + hex-rgb@4.3.0: {} 7308 7542 7309 7543 hosted-git-info@9.0.2: 7310 7544 dependencies: ··· 7567 7801 lightningcss-linux-x64-musl: 1.32.0 7568 7802 lightningcss-win32-arm64-msvc: 1.32.0 7569 7803 lightningcss-win32-x64-msvc: 1.32.0 7804 + 7805 + linebreak@1.1.0: 7806 + dependencies: 7807 + base64-js: 0.0.8 7808 + unicode-trie: 2.0.0 7570 7809 7571 7810 locate-path@6.0.0: 7572 7811 dependencies: ··· 7726 7965 7727 7966 package-manager-detector@1.6.0: {} 7728 7967 7968 + pako@0.2.9: {} 7969 + 7970 + parse-css-color@0.2.1: 7971 + dependencies: 7972 + color-name: 1.1.4 7973 + hex-rgb: 4.3.0 7974 + 7729 7975 parse-imports@3.0.0: 7730 7976 dependencies: 7731 7977 es-module-lexer: 1.7.0 ··· 7820 8066 fsevents: 2.3.2 7821 8067 7822 8068 pluralize@8.0.0: {} 8069 + 8070 + postcss-value-parser@4.2.0: {} 7823 8071 7824 8072 postcss@8.5.9: 7825 8073 dependencies: ··· 8081 8329 8082 8330 safer-buffer@2.1.2: {} 8083 8331 8332 + satori@0.26.0: 8333 + dependencies: 8334 + '@shuding/opentype.js': 1.4.0-beta.0 8335 + css-background-parser: 0.1.0 8336 + css-box-shadow: 1.0.0-3 8337 + css-gradient-parser: 0.0.17 8338 + css-to-react-native: 3.2.0 8339 + emoji-regex-xs: 2.0.1 8340 + escape-html: 1.0.3 8341 + linebreak: 1.1.0 8342 + parse-css-color: 0.2.1 8343 + postcss-value-parser: 4.2.0 8344 + yoga-layout: 3.2.1 8345 + 8084 8346 secure-json-parse@4.1.0: {} 8085 8347 8086 8348 semver@7.7.4: {} ··· 8208 8470 get-east-asian-width: 1.5.0 8209 8471 strip-ansi: 7.2.0 8210 8472 8473 + string.prototype.codepointat@0.2.1: {} 8474 + 8211 8475 string_decoder@1.3.0: 8212 8476 dependencies: 8213 8477 safe-buffer: 5.2.1 ··· 8301 8565 8302 8566 timekeeper@2.3.1: {} 8303 8567 8568 + tiny-inflate@1.0.3: {} 8569 + 8304 8570 tinyexec@1.1.1: {} 8305 8571 8306 8572 tinyglobby@0.2.16: ··· 8389 8655 undici@6.24.1: {} 8390 8656 8391 8657 unicode-segmenter@0.14.5: {} 8658 + 8659 + unicode-trie@2.0.0: 8660 + dependencies: 8661 + pako: 0.2.9 8662 + tiny-inflate: 1.0.3 8392 8663 8393 8664 unicorn-magic@0.3.0: {} 8394 8665 ··· 8489 8760 yocto-queue@0.1.0: {} 8490 8761 8491 8762 yoctocolors@2.1.2: {} 8763 + 8764 + yoga-layout@3.2.1: {} 8492 8765 8493 8766 youch-core@0.3.3: 8494 8767 dependencies:
resources/fonts/figtree-400.woff

This is a binary file and will not be displayed.

resources/fonts/figtree-700.woff

This is a binary file and will not be displayed.

resources/fonts/gabarito-700.woff

This is a binary file and will not be displayed.

+10
resources/views/pages/landing.edge
··· 1 1 @component('components/layout', { hideHeader: true }) 2 + @slot('head') 3 + <meta property="og:title" content="favs.blue" /> 4 + <meta property="og:description" content="See any Bluesky account's most popular posts." /> 5 + <meta property="og:image" content="{{ appUrl }}/og/landing.png" /> 6 + <meta property="og:image:width" content="1200" /> 7 + <meta property="og:image:height" content="630" /> 8 + <meta property="og:type" content="website" /> 9 + <meta property="og:url" content="{{ appUrl }}" /> 10 + <meta name="twitter:card" content="summary_large_image" /> 11 + @endslot 2 12 @slot('main') 3 13 <div class="pt-16 pb-12"> 4 14 <div class="flex items-center justify-between mb-2 animate-[fade-in-up_0.5s_var(--ease-out-quart)_both]">
+10
resources/views/pages/profile/show.edge
··· 2 2 @slot('title') 3 3 Top {{ kind === 'likes' ? 'liked' : 'reposted' }} posts of {{ '@' + handle }} — favs.blue 4 4 @endslot 5 + @slot('head') 6 + <meta property="og:title" content="{{ ogTitle }}" /> 7 + <meta property="og:description" content="{{ ogDescription }}" /> 8 + <meta property="og:image" content="{{ ogImageUrl }}" /> 9 + <meta property="og:image:width" content="1200" /> 10 + <meta property="og:image:height" content="630" /> 11 + <meta property="og:type" content="profile" /> 12 + <meta property="og:url" content="{{ canonicalUrl }}" /> 13 + <meta name="twitter:card" content="summary_large_image" /> 14 + @endslot 5 15 6 16 @slot('main') 7 17 <div class="pt-8 pb-4">
+9
start/routes.ts
··· 17 17 const OAuthController = () => import('#controllers/oauth_controller') 18 18 const EngagementController = () => import('#controllers/engagement_controller') 19 19 const HealthChecksController = () => import('#controllers/health_checks_controller') 20 + const OgImageController = () => import('#controllers/og_image_controller') 20 21 21 22 // --------------------------------------------------------------------------- 22 23 // Landing ··· 91 92 }) 92 93 .prefix('/api') 93 94 .use(middleware.auth()) 95 + 96 + // --------------------------------------------------------------------------- 97 + // OG images 98 + // --------------------------------------------------------------------------- 99 + 100 + router.get('/og/landing.png', [OgImageController, 'landing']).as('og.landing') 101 + 102 + router.get('/og/profile/:handle.png', [OgImageController, 'profile']).as('og.profile') 94 103 95 104 // --------------------------------------------------------------------------- 96 105 // Health checks
+38
tests/functional/og_image.spec.ts
··· 1 + import { test } from '@japa/runner' 2 + 3 + test.group('OG Image routes', () => { 4 + test('GET /og/landing.png returns a PNG with correct headers', async ({ client, assert }) => { 5 + const response = await client.get('/og/landing.png') 6 + response.assertStatus(200) 7 + response.assertHeader('content-type', 'image/png') 8 + assert.include(response.header('cache-control'), 'public') 9 + 10 + const body = response.body() as Buffer 11 + // PNG magic bytes 12 + assert.equal(body[0], 0x89) 13 + assert.equal(body[1], 0x50) 14 + }) 15 + 16 + test('GET /og/profile/:handle.png returns a PNG', async ({ client, assert }) => { 17 + const response = await client.get('/og/profile/dril.bsky.social.png') 18 + response.assertStatus(200) 19 + response.assertHeader('content-type', 'image/png') 20 + assert.include(response.header('cache-control'), 'public') 21 + 22 + const body = response.body() as Buffer 23 + assert.equal(body[0], 0x89) 24 + assert.equal(body[1], 0x50) 25 + }) 26 + }) 27 + 28 + test.group('OG meta tags', () => { 29 + test('landing page includes og:image meta tag', async ({ client, assert }) => { 30 + const response = await client.get('/') 31 + response.assertStatus(200) 32 + const html = response.text() 33 + assert.include(html, 'og:image') 34 + assert.include(html, '/og/landing.png') 35 + assert.include(html, 'twitter:card') 36 + assert.include(html, 'summary_large_image') 37 + }) 38 + })
+34
tests/unit/og_renderer.spec.ts
··· 1 + import { test } from '@japa/runner' 2 + import { renderLandingOgImage, renderProfileOgImage } from '#lib/og/renderer' 3 + 4 + test.group('OG Renderer', () => { 5 + test('renderLandingOgImage returns a PNG buffer', async ({ assert }) => { 6 + const result = await renderLandingOgImage() 7 + assert.instanceOf(result, Buffer) 8 + // PNG magic bytes 9 + assert.equal(result[0], 0x89) 10 + assert.equal(result[1], 0x50) // P 11 + assert.equal(result[2], 0x4e) // N 12 + assert.equal(result[3], 0x47) // G 13 + }) 14 + 15 + test('renderProfileOgImage returns a PNG buffer', async ({ assert }) => { 16 + const result = await renderProfileOgImage({ handle: 'dril.bsky.social' }) 17 + assert.instanceOf(result, Buffer) 18 + // PNG magic bytes 19 + assert.equal(result[0], 0x89) 20 + assert.equal(result[1], 0x50) // P 21 + assert.equal(result[2], 0x4e) // N 22 + assert.equal(result[3], 0x47) // G 23 + }) 24 + 25 + test('renderProfileOgImage uses display name when provided', async ({ assert }) => { 26 + const result = await renderProfileOgImage({ 27 + handle: 'dril.bsky.social', 28 + displayName: 'wint', 29 + }) 30 + assert.instanceOf(result, Buffer) 31 + // PNG magic bytes 32 + assert.equal(result[0], 0x89) 33 + }) 34 + })