├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── example.png ├── package.json ├── readme.md ├── source └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 18 13 | - run: npm install 14 | - run: npm test 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/astro-selfie/3125d0656200f4e8854721c7265bd6fc410e337c/example.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-selfie", 3 | "version": "1.0.2", 4 | "description": "Astro integration to generate page screenshots to show as Open Graph images", 5 | "license": "ISC", 6 | "repository": "vadimdemedes/astro-selfie", 7 | "author": { 8 | "name": "Vadim Demedes", 9 | "email": "vadimdemedes@hey.com", 10 | "url": "https://github.com/vadimdemedes" 11 | }, 12 | "type": "module", 13 | "exports": { 14 | "types": "./dist/index.d.ts", 15 | "default": "./dist/index.js" 16 | }, 17 | "engines": { 18 | "node": ">=14.16" 19 | }, 20 | "scripts": { 21 | "build": "tsc", 22 | "dev": "tsc --watch", 23 | "test": "prettier --check source && xo", 24 | "prepare": "rm -rf dist && tsc" 25 | }, 26 | "files": [ 27 | "dist" 28 | ], 29 | "keywords": [ 30 | "astro-integration" 31 | ], 32 | "dependencies": { 33 | "get-port": "^7.0.0", 34 | "micro": "^10.0.1", 35 | "playwright": "^1.34.3", 36 | "serve-handler": "^6.1.5" 37 | }, 38 | "devDependencies": { 39 | "@sindresorhus/tsconfig": "^3.0.1", 40 | "@types/react": "^18.2.9", 41 | "@types/serve-handler": "^6.1.1", 42 | "@vdemedes/prettier-config": "^2.0.1", 43 | "astro": "^2.6.1", 44 | "prettier": "^2.8.8", 45 | "typescript": "^5.1.3", 46 | "xo": "^0.54.2" 47 | }, 48 | "prettier": "@vdemedes/prettier-config", 49 | "xo": { 50 | "prettier": true, 51 | "rules": { 52 | "no-await-in-loop": "off" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # astro-selfie [![test](https://github.com/vadimdemedes/astro-selfie/actions/workflows/test.yml/badge.svg)](https://github.com/vadimdemedes/astro-selfie/actions/workflows/test.yml) 2 | 3 | > [Astro](https://astro.build) integration to generate page screenshots to show as Open Graph images. 4 | 5 | I use this extension on [my website](https://vadimdemedes.com) and my link previews on Twitter look [like this](https://twitter.com/vadimdemedes/status/1664261504168755201): 6 | 7 | 8 | 9 | Inspired by [Simon Willison's](https://simonwillison.net) website. 10 | 11 | ## Install 12 | 13 | ```console 14 | npm install --save-dev astro-selfie 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### 1. Set up integration 20 | 21 | Add this integration to `astro.config.mjs`: 22 | 23 | ```diff 24 | import {defineConfig} from 'astro/config'; 25 | + import selfie from 'astro-selfie'; 26 | 27 | export default defineConfig({ 28 | + integrations: [ 29 | + // Make sure `astro-selfie` only runs locally 30 | + !process.env['CI'] && !process.env['VERCEL'] && selfie() 31 | + ].filter(Boolean) 32 | }); 33 | ``` 34 | 35 | This integration is meant to be used locally for statically built websites for several reasons: 36 | 37 | 1. Websites deployed to Vercel don't have access to headless Chrome due to platform limitations. 38 | 2. Open graph images aren't probably useful in continuous integration. 39 | 3. Taking screenshots is not quick. 40 | 41 | ### 2. Add meta tags 42 | 43 | Then, add a `` tag to each page that points to a screenshot of itself. 44 | 45 | ```astro 46 | --- 47 | import {selfieUrl} from 'astro-selfie'; 48 | 49 | const screenshotUrl = selfieUrl(Astro); 50 | --- 51 | 52 | 53 | ``` 54 | 55 | ### 3. Customize styles (optional) 56 | 57 | Selfie adds a `data-astro-selfie` attribute to `body` when taking a screenshot. You can use that data attribute to change any styles in CSS to make sure page looks good. 58 | 59 | For example: 60 | 61 | ```css 62 | body[data-astro-selfie] .container { 63 | padding: 32px 64px; 64 | } 65 | ``` 66 | 67 | ### 4. Generate screenshots 68 | 69 | Run a build command to take screenshots of all pages and store them in `public/og` directory. 70 | 71 | ```console 72 | npx astro build 73 | ``` 74 | 75 | Once screenshots are generated, commit them to version control and deploy. 76 | 77 | ## API 78 | 79 | ### selfie() 80 | 81 | Returns an Astro integration that takes page screenshots. 82 | 83 | ### selfieUrl(astro): URL 84 | 85 | Returns a URL to the screenshot of the current page. 86 | 87 | #### astro 88 | 89 | Type: `AstroGlobal` 90 | 91 | Global `Astro` object. 92 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import http from 'node:http'; 3 | import {fileURLToPath} from 'node:url'; 4 | import fs from 'node:fs/promises'; 5 | import getPort from 'get-port'; 6 | import serveHandler from 'serve-handler'; 7 | import {serve} from 'micro'; 8 | import {chromium} from 'playwright'; 9 | import type {AstroGlobal, AstroIntegration} from 'astro'; 10 | 11 | export default function selfie(): AstroIntegration { 12 | let publicDir: URL; 13 | 14 | return { 15 | name: 'astro-selfie', 16 | hooks: { 17 | // eslint-disable-next-line @typescript-eslint/naming-convention, object-shorthand 18 | 'astro:config:done': ({config}) => { 19 | publicDir = (config as unknown as {publicDir: URL}).publicDir; 20 | }, 21 | // eslint-disable-next-line @typescript-eslint/naming-convention, object-shorthand 22 | 'astro:build:done': async ({dir, pages}) => { 23 | const screenshotsDir = new URL('og', publicDir); 24 | await fs.mkdir(fileURLToPath(screenshotsDir), {recursive: true}); 25 | 26 | const port = await getPort(); 27 | const baseUrl = new URL(`http://localhost:${port}`); 28 | 29 | const server = new http.Server( 30 | serve(async (request, response) => { 31 | await serveHandler(request, response, { 32 | public: fileURLToPath(dir), 33 | }); 34 | }), 35 | ); 36 | 37 | server.listen(port); 38 | 39 | const browser = await chromium.launch(); 40 | 41 | const context = await browser.newContext({ 42 | screen: { 43 | width: 1200, 44 | height: 600, 45 | }, 46 | viewport: { 47 | width: 1200, 48 | height: 600, 49 | }, 50 | }); 51 | 52 | for (const {pathname} of pages) { 53 | const url = new URL(pathname, baseUrl); 54 | const page = await context.newPage(); 55 | await page.goto(url.href); 56 | 57 | await page.evaluate('document.body.dataset.astroSelfie = true;'); 58 | 59 | const screenshot = await page.screenshot({type: 'png'}); 60 | 61 | const screenshotPath = path.join( 62 | fileURLToPath(screenshotsDir), 63 | pathname === '' ? 'index.png' : `${pathname}.png`, 64 | ); 65 | 66 | await fs.mkdir(path.dirname(screenshotPath), {recursive: true}); 67 | await fs.writeFile(screenshotPath, screenshot); 68 | } 69 | 70 | await browser.close(); 71 | server.close(); 72 | }, 73 | }, 74 | }; 75 | } 76 | 77 | const stripTrailingSlash = (input: string): string => { 78 | return input.replace(/\/$/, ''); 79 | }; 80 | 81 | const selfiePath = (astro: AstroGlobal): string => { 82 | const pathname = 83 | astro.url.pathname === '/' 84 | ? '/index' 85 | : stripTrailingSlash(astro.url.pathname); 86 | 87 | return `/og${pathname}.png`; 88 | }; 89 | 90 | export const selfieUrl = (astro: AstroGlobal): URL => { 91 | return new URL(selfiePath(astro), astro.site); 92 | }; 93 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "moduleResolution": "node16", 5 | "module": "node16", 6 | "outDir": "dist" 7 | }, 8 | "include": ["source"] 9 | } 10 | --------------------------------------------------------------------------------