├── .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 [](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 |
--------------------------------------------------------------------------------