├── .gitignore ├── release.config.json ├── tsup.config.ts ├── src ├── index.ts ├── performance.ts ├── babel.ts └── plugin.ts ├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.DS_Store 3 | /build -------------------------------------------------------------------------------- /release.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": [ 3 | { 4 | "name": "latest", 5 | "use": "pnpm publish --no-git-checks" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts', 'src/plugin.ts'], 5 | format: ['esm', 'cjs'], 6 | outDir: './build', 7 | shims: true, 8 | dts: true, 9 | clean: true, 10 | }) 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export const OPEN_GRAPH_USER_AGENT_HEADER = 'remix-og-image' 2 | 3 | export interface OpenGraphImageData { 4 | name: string 5 | params?: Record 6 | } 7 | 8 | export function isOpenGraphImageRequest(request: Request): boolean { 9 | return request.headers.get('user-agent') === OPEN_GRAPH_USER_AGENT_HEADER 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "outDir": "./build", 6 | "declaration": true, 7 | "declarationDir": "./build", 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "target": "es2022", 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "esModuleInterop": true, 14 | "verbatimModuleSyntax": true, 15 | "noUncheckedIndexedAccess": true, 16 | "noEmit": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/performance.ts: -------------------------------------------------------------------------------- 1 | import { 2 | performance as nativePerformance, 3 | PerformanceObserver, 4 | } from 'node:perf_hooks' 5 | 6 | const { LOG_PERFORMANCE } = process.env 7 | 8 | if (LOG_PERFORMANCE) { 9 | const observer = new PerformanceObserver((items) => { 10 | items.getEntries().forEach((entry) => { 11 | console.log(entry) 12 | }) 13 | }) 14 | 15 | observer.observe({ entryTypes: ['measure'], buffered: true }) 16 | } 17 | 18 | export const performance = LOG_PERFORMANCE 19 | ? nativePerformance 20 | : { 21 | mark() {}, 22 | measure() {}, 23 | } 24 | -------------------------------------------------------------------------------- /src/babel.ts: -------------------------------------------------------------------------------- 1 | export type { types as BabelTypes } from '@babel/core' 2 | export { parse, type ParseResult } from '@babel/parser' 3 | export type { NodePath, Binding } from '@babel/traverse' 4 | export type { GeneratorResult } from '@babel/generator' 5 | export * as t from '@babel/types' 6 | 7 | // Avoid CJS-ESM default export interop differences across different tools 8 | // https://github.com/babel/babel/issues/13855#issuecomment-945123514 9 | 10 | import { createRequire } from 'node:module' 11 | const require = createRequire(import.meta.url) 12 | 13 | // @ts-expect-error 14 | import _traverse = require('@babel/traverse') 15 | export const traverse = _traverse.default 16 | 17 | // @ts-expect-error 18 | import _generate = require('@babel/generator') 19 | export const generate = _generate.default 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | schedule: 5 | - cron: '0 1 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release: 10 | runs-on: macos-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | token: ${{ secrets.GH_ADMIN_TOKEN }} 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | always-auth: true 23 | registry-url: https://registry.npmjs.org 24 | 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | version: 8.15.6 28 | 29 | - name: Setup Git 30 | run: | 31 | git config --local user.name "Artem Zakharchenko" 32 | git config --local user.email "kettanaito@gmail.com" 33 | 34 | - name: Install dependencies 35 | run: pnpm install 36 | 37 | - name: Tests 38 | run: pnpm test 39 | 40 | - name: Release 41 | run: pnpm release 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GH_ADMIN_TOKEN }} 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "remix-og-image", 4 | "version": "0.6.12", 5 | "description": "", 6 | "main": "./build/index.js", 7 | "typings": "./build/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": "./build/index.js", 11 | "require": "./build/index.cjs" 12 | }, 13 | "./plugin": { 14 | "import": "./build/plugin.js", 15 | "require": "./build/plugin.cjs" 16 | } 17 | }, 18 | "files": [ 19 | "./build", 20 | "./src" 21 | ], 22 | "scripts": { 23 | "dev": "tsup --watch", 24 | "test": "exit 0", 25 | "build": "tsup", 26 | "release": "release publish", 27 | "prepack": "pnpm build", 28 | "postinstall": "npx playwright install chromium --with-deps" 29 | }, 30 | "keywords": [ 31 | "og", 32 | "image", 33 | "open-graph", 34 | "remix", 35 | "generate" 36 | ], 37 | "author": "Artem Zakharchenko ", 38 | "license": "MIT", 39 | "engines": { 40 | "node": ">=18.0.0" 41 | }, 42 | "dependencies": { 43 | "@babel/core": "^7.25.2", 44 | "@babel/generator": "^7.25.0", 45 | "@babel/parser": "^7.25.3", 46 | "@babel/traverse": "^7.25.3", 47 | "@babel/types": "^7.25.2", 48 | "@open-draft/deferred-promise": "^2.2.0", 49 | "@remix-run/dev": "^2.15.2", 50 | "@types/babel__core": "^7.20.5", 51 | "@types/babel__generator": "^7.6.8", 52 | "@types/babel__traverse": "^7.20.6", 53 | "@types/node": "^20", 54 | "babel-dead-code-elimination": "^1.0.6", 55 | "es-module-lexer": "^1.5.4", 56 | "path-to-regexp": "^7.1.0", 57 | "playwright": "^1.52.0", 58 | "sharp": "^0.33.5", 59 | "turbo-stream": "^2.4.0" 60 | }, 61 | "peerDependencies": { 62 | "@remix-run/dev": "^2.15.2", 63 | "typescript": "^5.1.0", 64 | "vite": "^5.1.0" 65 | }, 66 | "peerDependenciesMeta": { 67 | "@remix-run/dev": { 68 | "optional": true 69 | }, 70 | "typescript": { 71 | "optional": true 72 | }, 73 | "vite": { 74 | "optional": true 75 | } 76 | }, 77 | "devDependencies": { 78 | "@ossjs/release": "^0.8.1", 79 | "tsup": "^8.2.4", 80 | "typescript": "^5.5.4", 81 | "vite": "^5.4.1" 82 | } 83 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `remix-og-image` 2 | 3 | Build-time in-browser Open Graph image generation plugin for Remix. 4 | 5 | ## Motivation 6 | 7 | When I came to generating OG images for my Remix app, I realized that it's an unsolved problem. A general recommendation is to use something like [Satori](https://github.com/vercel/satori), which renders a React component to an SVG, and then use extra tools to convert the SVG into an image. 8 | 9 | That approach is nice, but it has some significant drawbacks. First, it's a _runtime_ generation. It literally happens when your user's browser hits `./og-image.jpeg`. It's another serverless function invocation, another compute, more latency, and all that comes with that. Second, such an approach is limited by design. You want a template for your image, and React is a great choice for that. But the only reliable way to render a React component is in the actual browser. Otherwise, you have to bring in additional resources and setup to make things work, like loading fonts, assets, etc. 10 | 11 | I really wanted a simple _built-time_ image generation. In fact, I've been using a custom script to generate images for my blog using Playwright for years. And it worked, it just was a bit disjoined from the rest of my build. 12 | 13 | Then it occured to me: why not take my script and turn it into a Vite plugin? You are looking at that plugin right now. 14 | 15 | ## Features 16 | 17 | The biggest feature of this plugin is the in-browser rendering. To put it extremely briefly: you create a route for OG images, write your React component template, and the plugin knows which routes to visit during the build, takes their screenshots using Playwright, and emits them on disk. 18 | 19 | Here's a longer version of that: 20 | 21 | - 🚀 **No limitations**. I mean it. Use the same styles, fonts, assets, components, utilities, and anything else you already use. 22 | - 💎 **Pixel-perfect rendering**. This plugin takes a screenshot of your special OG image route in the actual browser, giving you 1-1 browser rendering without compromise. 23 | - 👁️ **Retina-ready**. All images are generated with x2 device scale factor, then compressed and optimized to deliver the best quality/file size ratio. 24 | - 🛠️ **Build-time**. You want build-time OG image generation most of the time. This plugin does just that. Get the images on the disk, pay no runtime cost whatsoever. Both static and dynamic routes are supported! 25 | - 💅 **Interactive**. OG image is just a React component rendered in a route in your app. Visit that route to iterate your image and bring it to perfection. No extra steps to preview/debug it. It's literally a React component without magic. 26 | 27 | ## Usage 28 | 29 | ### Step 1: Install 30 | 31 | ```sh 32 | npm i remix-og-image 33 | ``` 34 | 35 | ### Step 2: Add plugin 36 | 37 | ```js 38 | // vite.config.js 39 | import { openGraphImage } from 'remix-og-image/plugin' 40 | 41 | export default defineConfig({ 42 | plugins: [ 43 | // ...the rest of your plugins. 44 | openGraphImage({ 45 | // Specify a selector for the DOM element on the page 46 | // that the plugin should screenshot. 47 | elementSelector: '#og-image', 48 | 49 | // Specify where to save the generated images. 50 | outputDirectory: './og', 51 | }), 52 | ], 53 | }) 54 | ``` 55 | 56 | ### Step 3: Create OG route 57 | 58 | This library needs a designated Remix route responsible for rendering OG images. Don't fret, it's your regular Remix route with _one_ tiny exception: 59 | 60 | ```jsx 61 | // app/routes/og.jsx 62 | import { json } from '@remix-run/react' 63 | import { 64 | isOpenGraphImageRequest, 65 | type OpenGraphImageData, 66 | } from 'remix-og-image' 67 | 68 | // 👉 1. Export the special `openGraphImage` function. 69 | // This function returns an array of OG image generation entries. 70 | // In the example below, it generates only one image called "og-image.jpeg" 71 | export function openGraphImage(): Array { 72 | return [ 73 | // The `name` property controls the generated 74 | // image's file name. 75 | { name: 'og-image' }, 76 | ] 77 | } 78 | 79 | // 2a. Add the `loader` export. 80 | export function loader({ request }) { 81 | // 👉 2b. First, check if the incoming request is a meta request 82 | // from the plugin. Use the `isOpenGraphImageRequest` utility from the library. 83 | if (isOpenGraphImageRequest(request)) { 84 | /** 85 | * @note In Remix before single fetch, you have to throw this response. 86 | */ 87 | return Response.json(openGraphImage()) 88 | } 89 | 90 | // Compute and return any data needed for the OG image. 91 | // In this case, this is a static route. 92 | return null 93 | } 94 | 95 | // 👉 3. Create a React component for your OG image. 96 | // Use whichever other components, styles, utilities, etc. 97 | // your app already has. No limits! 98 | export default function Template() { 99 | return ( 100 |
101 |

My site

102 |
103 | ) 104 | } 105 | ``` 106 | 107 | 108 | > [!IMPORTANT] 109 | > **The `openGraphImage` export is special**. Everything else is your regular Remix route. 110 | 111 | You can then reference the generated OG images in the `meta` export of your page: 112 | 113 | ```ts 114 | // app/routes/page.jsx 115 | export function meta() { 116 | return [ 117 | { 118 | name: 'og:image', 119 | content: '/og/og-image.jpeg', 120 | // 👆👆👆👆👆 121 | // This is the value of the `name` property 122 | // you provided in the `openGraphImage` export 123 | // of your OG image route. 124 | }, 125 | ] 126 | } 127 | ``` 128 | 129 | ## API 130 | 131 | ### `openGraphImage(options)` 132 | 133 | - `options` 134 | - `elementSelector`, `string`, a selector for the DOM element representing the OG image (i.e. your React component). The plugin takes the screenshot of the given element, and not the entire page, so you can render the OG image preview in the same layout as the rest of your app. 135 | - `outputDirectory`, `string`, a _relative_ path to the directory to write the image. Relative to the client build assets directory (e.g. `/build/client`). 136 | - `format`, `"jpeg" | "png" | "webp"` (_optional_; default, `"jpeg"`), the format of the generated image. 137 | - `writeImage`, `Function`, (_optional_), a custom function to control writing image. 138 | - `browser`, `Object`, Playwright browser instance options. 139 | - `executablePath`, `string`, (_optional_), a custom path to the Chromium executable. 140 | - `emulateMedia`, `Record` (_optional_), custom media features to apply to each created page (see [`page.emulateMedia()`](https://playwright.dev/docs/emulation#color-scheme-and-media)). Useful to force media features like `prefers-color-scheme`. 141 | 142 | ```js 143 | import { openGraphImage } from 'remix-og-image/plugin' 144 | ``` 145 | 146 | ### `isOpenGraphImageRequest(request)` 147 | 148 | - `request`, [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), a request reference from the `loader` function of your OG image route. 149 | - Returns `boolean` indicating whether the given `request` is the meta request performed by the plugin during the data scrapping for the `openGraphImage()` special export. 150 | 151 | ```js 152 | import { isOpenGraphImageRequest } from 'remix-og-image' 153 | ``` 154 | 155 | ## Recipes 156 | 157 | ### Dynamic data 158 | 159 | One of the selling points of generating OG images is including _dynamic data_ in them. 160 | 161 | This plugin supports generating multiple OG images from a single route by returning an array of data alternations from the `openGraphImage` function: 162 | 163 | ```tsx 164 | import type { OpenGraphImageData } from 'remix-og-image' 165 | 166 | // app/routes/post.$slug.og.jsx 167 | export function openGraphImage() { 168 | // Return a dynamic number of OG image entries 169 | // based on your data. The plugin will automatically 170 | // provide the "params" to this route when 171 | // visiting each alternation of this page in the browser. 172 | return allPosts.map((post) => { 173 | return { 174 | name: post.slug, 175 | params: { slug: post.slug }, 176 | } 177 | }) 178 | } 179 | 180 | export async function loader({ request, params }) { 181 | if (isOpenGraphImageRequest(request)) { 182 | return Response.jsopn(openGraphImage()) 183 | } 184 | 185 | const { slug } = params 186 | const post = await getPostBySlug(slug) 187 | 188 | return { post } 189 | } 190 | 191 | export default function Template() { 192 | const { post } = useLoaderData() 193 | 194 | return ( 195 |
199 |

{post.title}

200 |
201 | ) 202 | } 203 | ``` 204 | 205 | Use the same dynamic data you provided as `name` in the `openGraphImage` function to access the generated OG images in your route: 206 | 207 | ```jsx 208 | // app/routes/post.$slug.jsx 209 | 210 | export function meta({ params }) { 211 | const { slug } = params 212 | 213 | // ...validate the params. 214 | 215 | return [ 216 | { 217 | name: 'og:image', 218 | content: `/og/${slug}.jpeg`, 219 | // 👆👆👆👆 220 | }, 221 | ] 222 | } 223 | ``` 224 | 225 | ## Frequently Asked Questions 226 | 227 | ### How does this plugin work? 228 | 229 | 1. The plugin spawns a single Chromium instance. 230 | 1. The plugin finds any routes with the `openGraphImage()` export. 231 | 1. The plugin requests the route as a data route (a special request) to get whatever you returned from the `openGraphImage()` function. In response, the plugin receives the list of OG images (and their data) to generate. 232 | 1. The plugin iterates over each OG image entry, visiting the route in the browser, providing it whatever `params` you provided in the `openGraphImage()` function. This way, it support dynamic OG images! 233 | 1. Finally, the plugin takes a screenshot of the OG image element on the page, and writes it as an image to disk. 🎉 234 | 235 | ### How to set the image size? 236 | 237 | This plugin treats your OG image React component as the source of truth. The dimensions of your OG image will be the same as the dimensions of the DOM element representing it. 238 | 239 | ```jsx 240 | export default function Template() { 241 | return ( 242 |
243 | Hello world! 244 |
245 | ) 246 | } 247 | ``` 248 | 249 | > For example, this `Template` component renders the `#og-image` element as a `1200x630` block. That will be the size of the generated OG image. 250 | 251 | ### What if I don't want to write images to disk? 252 | 253 | You can opt-out from writing generated images to disk by providing the `writeImage` option to the plugin: 254 | 255 | ```js 256 | openGraphImage({ 257 | // ...options. 258 | 259 | async writeImage(image) { 260 | await uploadToCdn(image.stream()) 261 | }, 262 | }) 263 | ``` 264 | 265 | > The `image` argument is a regular `File`. 266 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import { PassThrough, Readable } from 'node:stream' 4 | import { finished } from 'node:stream/promises' 5 | import { 6 | type Plugin, 7 | type ResolvedConfig, 8 | type ViteDevServer, 9 | createServer, 10 | resolveConfig, 11 | normalizePath, 12 | } from 'vite' 13 | import { parse as esModuleLexer } from 'es-module-lexer' 14 | import { decode } from 'turbo-stream' 15 | import sharp from 'sharp' 16 | import type { VitePluginConfig as RemixVitePluginConfig } from '@remix-run/dev' 17 | import type { RouteConfigEntry } from '@remix-run/dev/dist/config/routes.js' 18 | import { DeferredPromise } from '@open-draft/deferred-promise' 19 | import { 20 | deadCodeElimination, 21 | findReferencedIdentifiers, 22 | } from 'babel-dead-code-elimination' 23 | import { compile } from 'path-to-regexp' 24 | import { type Browser, type Page, chromium } from 'playwright' 25 | import { performance } from './performance.js' 26 | import { parse, traverse, generate, t } from './babel.js' 27 | import { 28 | OPEN_GRAPH_USER_AGENT_HEADER, 29 | type OpenGraphImageData, 30 | } from './index.js' 31 | 32 | interface Options { 33 | /** 34 | * Selector for the element to capture as the Open Graph image. 35 | * @example "#og-image" 36 | */ 37 | elementSelector: string 38 | 39 | /** 40 | * Relative path to the directory to store generated images. 41 | * Relative to the client build assets (e.g. `/build/client`). 42 | */ 43 | outputDirectory: string 44 | 45 | /** 46 | * Path to the debug directory to store any error-related screenshots. 47 | */ 48 | debugDirectory?: string 49 | 50 | /** 51 | * Format of the generated image. 52 | * @default "jpeg" 53 | */ 54 | format?: 'jpeg' | 'png' | 'webp' 55 | 56 | writeImage?: (image: { stream: Readable }) => Promise 57 | 58 | beforeScreenshot?: (args: { page: Page }) => Promise 59 | 60 | browser?: { 61 | executablePath?: string 62 | 63 | /** 64 | * Custom media features. 65 | * Use this to force media features like `prefers-color-scheme` 66 | * or `prefers-reduced-motion`. 67 | * 68 | * @example 69 | * mediaFeatures: { 70 | * 'prefers-color-scheme': 'dark', 71 | * } 72 | */ 73 | mediaFeatures?: Parameters[0] 74 | } 75 | } 76 | 77 | interface RemixPluginContext { 78 | remixConfig: Required 79 | useSingleFetch: boolean 80 | } 81 | 82 | interface GeneratedOpenGraphImage { 83 | name: string 84 | path: string 85 | stream: Readable 86 | } 87 | 88 | interface CacheEntry { 89 | routeLastModifiedAt: number 90 | images: Array<{ 91 | name: string 92 | outputPath: string 93 | }> 94 | } 95 | 96 | const PLUGIN_NAME = 'remix-og-image-plugin' 97 | const EXPORT_NAME = 'openGraphImage' 98 | const CACHE_DIR = path.resolve('node_modules/.cache/remix-og-image') 99 | const CACHE_MANIFEST = path.resolve(CACHE_DIR, 'manifest.json') 100 | const CACHE_RESULTS_DIR = path.resolve(CACHE_DIR, 'output') 101 | 102 | export function openGraphImage(options: Options): Plugin { 103 | if (path.isAbsolute(options.outputDirectory)) { 104 | throw new Error( 105 | `Failed to initialize plugin: expected "outputDirectory" to be a relative path but got "${options.outputDirectory}". Please make sure it starts with "./".`, 106 | ) 107 | } 108 | 109 | const format = options.format || 'jpeg' 110 | 111 | const cache = new Cache() 112 | const browserPromise = new DeferredPromise() 113 | const vitePreviewPromise = new DeferredPromise() 114 | const viteConfigPromise = new DeferredPromise() 115 | const remixContextPromise = new DeferredPromise() 116 | const routesWithImages = new Set() 117 | 118 | async function fromRemixApp(...paths: Array): Promise { 119 | const remixContext = await remixContextPromise 120 | return path.resolve(remixContext.remixConfig.appDirectory, ...paths) 121 | } 122 | 123 | async function fromViteBuild(...paths: Array): Promise { 124 | const remixContext = await remixContextPromise 125 | return path.resolve( 126 | remixContext.remixConfig.buildDirectory, 127 | 'client', 128 | ...paths, 129 | ) 130 | } 131 | 132 | async function fromOutputDirectory(...paths: Array): Promise { 133 | return fromViteBuild(options.outputDirectory, ...paths) 134 | } 135 | 136 | async function writeDebugScreenshot(name: string, page: Page): Promise { 137 | if (!options.debugDirectory) { 138 | return 139 | } 140 | 141 | const buffer = await page.screenshot({ type: 'png' }) 142 | const screenshotPath = path.join(options.debugDirectory, `${name}.png`) 143 | const screenshotBaseDirectory = path.dirname(screenshotPath) 144 | await ensureDirectory(screenshotBaseDirectory) 145 | 146 | await fs.promises.writeFile(screenshotPath, buffer) 147 | } 148 | 149 | async function generateOpenGraphImages( 150 | route: RouteConfigEntry, 151 | browser: Browser, 152 | appUrl: URL, 153 | ): Promise> { 154 | if (!route.path) { 155 | return [] 156 | } 157 | 158 | performance.mark(`generate-image-${route.file}-start`) 159 | 160 | // See if the route already has images generated in the cache. 161 | const cacheEntry = cache.get(route.file) 162 | const routeStats = await fs.promises 163 | .stat(await fromRemixApp(route.file)) 164 | .catch((error) => { 165 | console.log('Failed to read stats for route "%s"', route.file, error) 166 | throw error 167 | }) 168 | const routeLastModifiedAt = routeStats.mtimeMs 169 | 170 | if (cacheEntry) { 171 | const hasRouteChanged = 172 | routeLastModifiedAt > cacheEntry.routeLastModifiedAt 173 | 174 | // If the route hasn't changed, and there are cached generated results, 175 | // copy the generated images without spawning the browser, screenshoting, etc. 176 | if (!hasRouteChanged) { 177 | await Promise.all( 178 | cacheEntry.images.map((cachedImage) => { 179 | const cachedImagePath = path.resolve( 180 | CACHE_RESULTS_DIR, 181 | cachedImage.name, 182 | ) 183 | 184 | if (fs.existsSync(cachedImagePath)) { 185 | return writeImage({ 186 | name: cachedImage.name, 187 | path: cachedImage.outputPath, 188 | stream: fs.createReadStream(cachedImagePath), 189 | }) 190 | } 191 | }), 192 | ) 193 | 194 | /** 195 | * @fixme If copying the cached images fails for any reason, 196 | * the plugin should continue ONLY with the sub-list of images 197 | * that excludes those that were successfully copied from the cache. 198 | */ 199 | return [] 200 | } 201 | } 202 | 203 | const remixContext = await remixContextPromise 204 | const createRoutePath = compile(route.path) 205 | 206 | performance.mark(`generate-image-${route.id}-loader-start`) 207 | 208 | // Fetch all the params data from the route. 209 | const loaderData = await getLoaderData( 210 | route, 211 | appUrl, 212 | remixContext.useSingleFetch, 213 | ) 214 | 215 | performance.mark(`generate-image-${route.id}-loader-end`) 216 | performance.measure( 217 | `generate-image-${route.id}:loader`, 218 | `generate-image-${route.id}-loader-start`, 219 | `generate-image-${route.id}-loader-end`, 220 | ) 221 | 222 | const images: Array = [] 223 | 224 | await Promise.all( 225 | loaderData.map(async (data) => { 226 | performance.mark(`generate-image-${route.id}-${data.name}-start`) 227 | 228 | performance.mark( 229 | `generate-image-${route.id}-${data.name}-new-page-start`, 230 | ) 231 | 232 | const page = await browser.newPage({ 233 | // Use a larger scale factor to get a crisp image. 234 | deviceScaleFactor: 2, 235 | // Set viewport to a 5K device equivalent. 236 | // This is more than enough to ensure that the OG image is visible. 237 | viewport: { 238 | width: 5120, 239 | height: 2880, 240 | }, 241 | }) 242 | 243 | // Support custom user preferences (media features), 244 | // such as forcing a light/dark mode for the app. 245 | const mediaFeatures = options.browser?.mediaFeatures 246 | if (mediaFeatures) { 247 | await page.emulateMedia(mediaFeatures) 248 | } 249 | 250 | performance.mark(`generate-image-${route.id}-${data.name}-new-page-end`) 251 | performance.measure( 252 | `generate-image-${route.id}-${data.name}:new-page`, 253 | `generate-image-${route.id}-${data.name}-new-page-start`, 254 | `generate-image-${route.id}-${data.name}-new-page-end`, 255 | ) 256 | 257 | try { 258 | const pageUrl = new URL(createRoutePath(data.params), appUrl).href 259 | 260 | performance.mark( 261 | `generate-image-${route.id}-${data.name}-pageload-start`, 262 | ) 263 | 264 | page.on('pageerror', (error) => { 265 | console.error( 266 | 'Page error while rendering OG image component for "%s"', 267 | data.name, 268 | ) 269 | console.error(error) 270 | }) 271 | 272 | await page.goto(pageUrl, { waitUntil: 'domcontentloaded' }) 273 | 274 | // Support running arbitrary logic before locating the element 275 | // and taking a screenshot of it. 276 | await options?.beforeScreenshot?.({ page }) 277 | 278 | performance.mark( 279 | `generate-image-${route.id}-${data.name}-pageload-end`, 280 | ) 281 | performance.measure( 282 | `generate-image-${route.id}-${data.name}:pageload`, 283 | `generate-image-${route.id}-${data.name}-pageload-start`, 284 | `generate-image-${route.id}-${data.name}-pageload-end`, 285 | ) 286 | 287 | const element = page.locator(options.elementSelector) 288 | 289 | await element.waitFor({ state: 'visible' }).catch(async (error) => { 290 | await writeDebugScreenshot(`${data.name}-element-not-found`, page) 291 | throw new Error( 292 | `Failed to generate OG image for "${data.name}": OG image element not found`, 293 | { cause: error }, 294 | ) 295 | }) 296 | 297 | const ogImageBoundingBox = await element.boundingBox() 298 | 299 | if (!ogImageBoundingBox) { 300 | await writeDebugScreenshot( 301 | `${data.name}-element-no-bounding-box`, 302 | page, 303 | ) 304 | 305 | throw new Error( 306 | `Failed to generate OG image for "${data.name}": cannot calculate the bounding box`, 307 | ) 308 | } 309 | 310 | performance.mark( 311 | `generate-image-${route.id}-${data.name}-screenshot-start`, 312 | ) 313 | 314 | const imageBuffer = await page.screenshot({ 315 | // Always take screenshots as png. 316 | // This allows for initial transparency. The end image format 317 | // will be decided by `sharp` later. 318 | type: 'png', 319 | // Set an explicit `clip` boundary for the screenshot 320 | // to capture only the image and ignore any otherwise 321 | // present UI, like the layout. 322 | clip: ogImageBoundingBox, 323 | }) 324 | 325 | performance.mark( 326 | `generate-image-${route.id}-${data.name}-screenshot-end`, 327 | ) 328 | performance.measure( 329 | `generate-image-${route.id}-${data.name}:screenshot`, 330 | `generate-image-${route.id}-${data.name}-screenshot-start`, 331 | `generate-image-${route.id}-${data.name}-screenshot-end`, 332 | ) 333 | 334 | let imageStream = sharp(imageBuffer).on('error', (error) => { 335 | throw new Error(`Image stream failed!`, { cause: error }) 336 | }) 337 | 338 | switch (format) { 339 | case 'jpeg': { 340 | imageStream = imageStream.jpeg({ 341 | quality: 100, 342 | progressive: true, 343 | }) 344 | break 345 | } 346 | 347 | case 'png': { 348 | imageStream = imageStream.png({ 349 | compressionLevel: 9, 350 | adaptiveFiltering: true, 351 | }) 352 | break 353 | } 354 | 355 | case 'webp': { 356 | imageStream = imageStream.webp({ 357 | lossless: true, 358 | smartSubsample: true, 359 | quality: 100, 360 | preset: 'picture', 361 | }) 362 | break 363 | } 364 | } 365 | 366 | const imageName = `${data.name}.${format}` 367 | 368 | images.push({ 369 | name: imageName, 370 | path: await fromOutputDirectory(imageName), 371 | stream: imageStream, 372 | }) 373 | } finally { 374 | await page.close({ runBeforeUnload: false }) 375 | 376 | performance.mark(`generate-image-${route.id}-${data.name}-end`) 377 | performance.measure( 378 | `generate-image-${route.id}-${data.name}`, 379 | `generate-image-${route.id}-${data.name}-start`, 380 | `generate-image-${route.id}-${data.name}-end`, 381 | ) 382 | } 383 | }), 384 | ) 385 | 386 | performance.mark(`generate-image-${route.id}-end`) 387 | performance.measure( 388 | `generate-image-${route.id}`, 389 | `generate-image-${route.id}-start`, 390 | `generate-image-${route.id}-end`, 391 | ) 392 | 393 | cache.set(route.file, { 394 | routeLastModifiedAt, 395 | images: images.map((image) => ({ 396 | name: image.name, 397 | outputPath: image.path, 398 | })), 399 | }) 400 | 401 | return images 402 | } 403 | 404 | async function writeImage(image: GeneratedOpenGraphImage) { 405 | if (options.writeImage) { 406 | return await options.writeImage({ 407 | stream: image.stream, 408 | }) 409 | } 410 | 411 | await Promise.all([ 412 | ensureDirectory(path.dirname(image.path)), 413 | ensureDirectory(CACHE_RESULTS_DIR), 414 | ]) 415 | 416 | const passthrough = new PassThrough() 417 | const imageWriteStream = fs.createWriteStream(image.path) 418 | const cacheWriteStream = fs.createWriteStream( 419 | path.resolve(CACHE_RESULTS_DIR, image.name), 420 | ) 421 | 422 | passthrough.pipe(imageWriteStream) 423 | passthrough.pipe(cacheWriteStream) 424 | image.stream.pipe(passthrough) 425 | 426 | await Promise.all([finished(imageWriteStream), finished(cacheWriteStream)]) 427 | 428 | console.log(`Generated OG image at "${image.path}".`) 429 | } 430 | 431 | performance.mark('plugin-start') 432 | 433 | return { 434 | name: PLUGIN_NAME, 435 | 436 | apply: 'build', 437 | 438 | async buildStart() { 439 | const viteConfig = await viteConfigPromise 440 | await cache.open(path.resolve(viteConfig.root, CACHE_MANIFEST)) 441 | }, 442 | 443 | configResolved(config) { 444 | viteConfigPromise.resolve(config) 445 | 446 | const reactRouterContext = Reflect.get( 447 | config, 448 | '__reactRouterPluginContext', 449 | ) 450 | 451 | // react-router 452 | if (reactRouterContext) { 453 | remixContextPromise.resolve({ 454 | remixConfig: reactRouterContext.reactRouterConfig, 455 | useSingleFetch: true, 456 | }) 457 | return 458 | } 459 | 460 | const remixContext = Reflect.get(config, '__remixPluginContext') 461 | 462 | if (typeof remixContext === 'undefined') { 463 | throw new Error( 464 | `Failed to apply "remix-og-image" plugin: no Remix context found. Did you forget to use the Remix plugin in your Vite configuration?`, 465 | ) 466 | } 467 | 468 | remixContextPromise.resolve({ 469 | remixConfig: remixContext.remixConfig, 470 | useSingleFetch: 471 | !!Reflect.get( 472 | remixContext.remixConfig.future, 473 | 'unstable_singleFetch', 474 | ) || !!Reflect.get(remixContext.remixConfig.future, 'v3_singleFetch'), 475 | }) 476 | }, 477 | 478 | async transform(code, id, transformOptions = {}) { 479 | const remixContext = await remixContextPromise 480 | 481 | if (!remixContext) { 482 | return 483 | } 484 | 485 | const routePath = normalizePath( 486 | path.relative(remixContext.remixConfig.appDirectory, id), 487 | ) 488 | const route = Object.values(remixContext.remixConfig.routes).find( 489 | (route) => { 490 | return normalizePath(route.file) === routePath 491 | }, 492 | ) 493 | 494 | // Ignore non-route modules. 495 | if (!route) { 496 | return 497 | } 498 | 499 | const [, routeExports] = esModuleLexer(code) 500 | 501 | // Ignore routes that don't have the root-level special export. 502 | const hasSpecialExport = 503 | routeExports.findIndex((e) => e.n === EXPORT_NAME) !== -1 504 | 505 | if (!hasSpecialExport) { 506 | return 507 | } 508 | 509 | // OG image generation must only happen server-side. 510 | if (!transformOptions.ssr) { 511 | // Parse the route module and remove the special export altogether. 512 | // This way, it won't be present in the client bundle, and won't affect 513 | // "vite-plugin-react" and its HMR. 514 | const ast = parse(code, { sourceType: 'module' }) 515 | const refs = findReferencedIdentifiers(ast) 516 | 517 | traverse(ast, { 518 | ExportNamedDeclaration(path) { 519 | if ( 520 | t.isFunctionDeclaration(path.node.declaration) && 521 | t.isIdentifier(path.node.declaration.id) && 522 | path.node.declaration.id.name === EXPORT_NAME 523 | ) { 524 | path.remove() 525 | } 526 | }, 527 | }) 528 | 529 | // Use DCE to remove any references the special export might have had. 530 | deadCodeElimination(ast, refs) 531 | return generate(ast, { sourceMaps: true, sourceFileName: id }, code) 532 | } 533 | 534 | // Spawn the browser immediately once we detect an OG image route. 535 | if (routesWithImages.size === 0) { 536 | browserPromise.resolve(getBrowserInstance(options.browser)) 537 | vitePreviewPromise.resolve( 538 | runVitePreviewServer(await viteConfigPromise), 539 | ) 540 | } 541 | 542 | routesWithImages.add(route) 543 | }, 544 | 545 | // Use `writeBundle` and not `closeBundle` so the image generation 546 | // time is counted toward the total build time. 547 | writeBundle: { 548 | order: 'post', 549 | async handler() { 550 | const viteConfig = await viteConfigPromise 551 | const isBuild = viteConfig.command === 'build' 552 | const isServerBuild = 553 | viteConfig.build.rollupOptions.input === 554 | 'virtual:remix/server-build' || 555 | // react-router 556 | viteConfig.build.rollupOptions.input === 557 | 'virtual:react-router/server-build' 558 | 559 | if ( 560 | isBuild && 561 | /** 562 | * @fixme This is a hacky way of knowing the build end. 563 | * The problem is that `closeBundle` will trigger MULTIPLE times, 564 | * as there are multiple bundles Remix builds (client, server, etc). 565 | * This plugin has to run after the LASTMOST bundle. 566 | */ 567 | isServerBuild 568 | ) { 569 | console.log( 570 | `Generating OG images for ${routesWithImages.size} route(s): 571 | ${Array.from(routesWithImages) 572 | .map((route) => ` - ${route.id}`) 573 | .join('\n')} 574 | `, 575 | ) 576 | 577 | const [browser, server] = await Promise.all([ 578 | browserPromise, 579 | 580 | /** 581 | * @fixme Vite preview server someties throws: 582 | * "Error: The server is being restarted or closed. Request is outdated." 583 | * when trying to navigate to it. It requires a refresh to work. 584 | */ 585 | vitePreviewPromise, 586 | ]) 587 | const appUrl = new URL(server.resolvedUrls?.local?.[0]!) 588 | const pendingScreenshots: Array> = [] 589 | 590 | for (const route of routesWithImages) { 591 | pendingScreenshots.push( 592 | generateOpenGraphImages(route, browser, appUrl) 593 | .then((images) => Promise.all(images.map(writeImage))) 594 | .catch((error) => { 595 | throw new Error( 596 | `Failed to generate OG image for route "${route.id}".`, 597 | { cause: error }, 598 | ) 599 | }), 600 | ) 601 | } 602 | 603 | await Promise.all(pendingScreenshots) 604 | .catch((error) => { 605 | console.error(error) 606 | throw new Error( 607 | 'Failed to generate OG images. Please see the errors above.', 608 | ) 609 | }) 610 | .finally(async () => { 611 | await Promise.all([ 612 | server.close(), 613 | browser.close(), 614 | cache.close(), 615 | ]) 616 | 617 | performance.mark('plugin-end') 618 | performance.measure('plugin', 'plugin-start', 'plugin-end') 619 | }) 620 | } 621 | }, 622 | }, 623 | } 624 | } 625 | 626 | let browser: Browser | undefined 627 | 628 | async function getBrowserInstance( 629 | options: Options['browser'] = {}, 630 | ): Promise { 631 | if (browser) { 632 | return browser 633 | } 634 | 635 | performance.mark('browser-launch-start') 636 | 637 | browser = await chromium.launch({ 638 | headless: true, 639 | args: [ 640 | '--no-sandbox', 641 | '--disable-setuid-sandbox', 642 | '--disable-gpu', 643 | '--disable-software-rasterizer', 644 | ], 645 | executablePath: options.executablePath, 646 | }) 647 | 648 | performance.mark('browser-launch-end') 649 | performance.measure( 650 | 'browser-launch', 651 | 'browser-launch-start', 652 | 'browser-launch-end', 653 | ) 654 | 655 | return browser 656 | } 657 | 658 | async function runVitePreviewServer( 659 | viteConfig: ResolvedConfig, 660 | ): Promise { 661 | /** 662 | * @note Force `NODE_ENV` to be "development" for the preview server. 663 | * Vite sets certain internal flags based on the environment, and there 664 | * is no way to override that. The build is triggered with "production", 665 | * but the preview server MUST be "development". This also makes sure 666 | * that all the used plugins are instantiated correctly. 667 | */ 668 | process.env.NODE_ENV = 'development' 669 | 670 | performance.mark('vite-preview-resolve-config-start') 671 | 672 | // Use the `resolveConfig` function explicitly because it 673 | // allows passing options like `command` and `mode` to Vite. 674 | const previewViteConfig = await resolveConfig( 675 | { 676 | root: viteConfig.root, 677 | configFile: viteConfig.configFile, 678 | logLevel: 'error', 679 | optimizeDeps: { 680 | noDiscovery: true, 681 | }, 682 | }, 683 | 'serve', 684 | // Using `production` mode is important. 685 | // It will skip all the built-in development-oriented plugins in Vite. 686 | 'production', 687 | 'development', 688 | false, 689 | ) 690 | 691 | performance.mark('vite-preview-resolve-config-end') 692 | performance.measure( 693 | 'vite-preview-resolve-config', 694 | 'vite-preview-resolve-config-start', 695 | 'vite-preview-resolve-config-end', 696 | ) 697 | 698 | performance.mark('vite-preview-server-start') 699 | 700 | const server = await createServer(previewViteConfig.inlineConfig) 701 | 702 | performance.mark('vite-preview-server-end') 703 | performance.measure( 704 | 'vite-preview-server', 705 | 'vite-preview-server-start', 706 | 'vite-preview-server-end', 707 | ) 708 | 709 | return server.listen() 710 | } 711 | 712 | class Cache extends Map { 713 | private cachePath?: string 714 | 715 | constructor() { 716 | super() 717 | } 718 | 719 | public async open(cachePath: string): Promise { 720 | if (this.cachePath) { 721 | return 722 | } 723 | 724 | this.cachePath = cachePath 725 | 726 | if (fs.existsSync(this.cachePath)) { 727 | const cacheContent = await fs.promises 728 | .readFile(this.cachePath, 'utf-8') 729 | .then(JSON.parse) 730 | .catch(() => ({})) 731 | 732 | for (const [key, value] of Object.entries(cacheContent)) { 733 | this.set(key as K, value as V) 734 | } 735 | } 736 | } 737 | 738 | public async close(): Promise { 739 | if (!this.cachePath) { 740 | throw new Error(`Failed to close cache: cache is not open`) 741 | } 742 | 743 | const cacheContent = JSON.stringify(Object.fromEntries(this.entries())) 744 | const baseDirectory = path.dirname(this.cachePath) 745 | if (!fs.existsSync(baseDirectory)) { 746 | await fs.promises.mkdir(baseDirectory, { recursive: true }) 747 | } 748 | return fs.promises.writeFile(this.cachePath, cacheContent, 'utf8') 749 | } 750 | } 751 | 752 | async function getLoaderData( 753 | route: RouteConfigEntry, 754 | appUrl: URL, 755 | useSingleFetch?: boolean, 756 | ) { 757 | const url = createResourceRouteUrl(route, appUrl, useSingleFetch) 758 | const response = await fetch(url, { 759 | headers: { 760 | 'user-agent': OPEN_GRAPH_USER_AGENT_HEADER, 761 | }, 762 | }).catch((error) => { 763 | throw new Error( 764 | `Failed to fetch Open Graph image data for route "${url.href}": ${error}`, 765 | ) 766 | }) 767 | 768 | if (!response.ok) { 769 | throw new Error( 770 | `Failed to fetch Open Graph image data for route "${url.href}": loader responsed with ${response.status}`, 771 | ) 772 | } 773 | 774 | if (!response.body) { 775 | throw new Error( 776 | `Failed to fetch Open Graph image data for route "${url.href}": loader responsed with no body. Did you forget to throw \`json(openGraphImage())\` in your loader?`, 777 | ) 778 | } 779 | 780 | const responseContentType = response.headers.get('content-type') || '' 781 | const expectedContentTypes = useSingleFetch 782 | ? ['text/x-turbo', 'text/x-script'] 783 | : ['application/json'] 784 | 785 | if (!expectedContentTypes.includes(responseContentType)) { 786 | throw new Error( 787 | `Failed to fetch Open Graph image data for route "${url.href}": loader responsed with invalid content type ("${responseContentType}"). Did you forget to throw \`json(openGraphImage())\` in your loader?`, 788 | ) 789 | } 790 | 791 | // Consume the loader response based on the fetch mode. 792 | const data = await consumeLoaderResponse(response, route, useSingleFetch) 793 | 794 | if (!Array.isArray(data)) { 795 | throw new Error( 796 | `Failed to fetch Open Graph image data for route "${url.href}": loader responded with invalid response. Did you forget to throw \`json(openGraphImage())\` in your loader?`, 797 | ) 798 | } 799 | 800 | return data 801 | } 802 | 803 | /** 804 | * Create a URL for the given route so it can be queried 805 | * as a resource route. Respects Single fetch mode. 806 | */ 807 | function createResourceRouteUrl( 808 | route: RouteConfigEntry, 809 | appUrl: URL, 810 | useSingleFetch?: boolean, 811 | ) { 812 | if (!route.path) { 813 | throw new Error( 814 | `Failed to create resource route URL for route "${route.id}": route has no path`, 815 | ) 816 | } 817 | 818 | const url = new URL(route.path, appUrl) 819 | 820 | if (useSingleFetch) { 821 | url.pathname += '.data' 822 | /** 823 | * @note The `_routes` parameter is meant for fetching multiple loader 824 | * data that match the route. It won't work if you have multiple different, 825 | * independent routes, so we still need to fetch the loader data in multiple requests. 826 | */ 827 | url.searchParams.set('_route', route.id!) 828 | } else { 829 | // Set the "_data" search parameter so the route can be queried 830 | // like a resource route although it renders UI. 831 | url.searchParams.set('_data', route.id!) 832 | } 833 | 834 | return url 835 | } 836 | 837 | async function decodeTurboStreamResponse( 838 | response: Response, 839 | ): Promise> { 840 | if (!response.body) { 841 | throw new Error( 842 | `Failed to decode turbo-stream response: response has no body`, 843 | ) 844 | } 845 | 846 | const bodyStream = await decode(response.body) 847 | await bodyStream.done 848 | 849 | const decodedBody = bodyStream.value as Record 850 | if (!decodedBody) { 851 | throw new Error(`Failed to decode turbo-stream response`) 852 | } 853 | 854 | return decodedBody 855 | } 856 | 857 | async function consumeLoaderResponse( 858 | response: Response, 859 | route: RouteConfigEntry, 860 | useSingleFetch?: boolean, 861 | ): Promise> { 862 | if (!response.body) { 863 | throw new Error(`Failed to read loader response: response has no body`) 864 | } 865 | 866 | // If the app is using Single Fetch, decode the loader 867 | // payload properly using the `turbo-stream` package. 868 | if (useSingleFetch) { 869 | const decodedBody = await decodeTurboStreamResponse(response) 870 | const routePayload = decodedBody[route.id!] 871 | 872 | if (!routePayload) { 873 | throw new Error( 874 | `Failed to consume loader response for route "${route.id}": route not found in decoded response`, 875 | ) 876 | } 877 | 878 | const data = (routePayload as any).data as Array 879 | 880 | if (!data) { 881 | throw new Error( 882 | `Failed to consume loader response for route "${route.id}": route has no data`, 883 | ) 884 | } 885 | 886 | return data 887 | } 888 | 889 | // If the app is using the legacy loader response, 890 | // read it as JSON (it's not encoded). 891 | return response.json() 892 | } 893 | 894 | async function ensureDirectory(directory: string): Promise { 895 | if (fs.existsSync(directory)) { 896 | return 897 | } 898 | await fs.promises.mkdir(directory, { recursive: true }) 899 | } 900 | --------------------------------------------------------------------------------