Skip to content

Commit dba64ed

Browse files
Dimitri POSTOLOVsaihaj
Dimitri POSTOLOV
andauthored
add open graph images + multi langs + image snapshots tests (graphprotocol#431)
* temp * fix * remove unused * move tests * add tests * fixes * fixes * fixes * fixes * fixes * like this * prettier * lint * ci: job to deploy opengraph service * style: run prettier * add a way to deploy manually * run temp * Revert "run temp" This reverts commit aa13fa5. * increase memory * use the worker * only deploy if service has changed --------- Co-authored-by: Saihajpreet Singh <[email protected]>
1 parent 9f7e9e0 commit dba64ed

32 files changed

+3135
-108
lines changed

.github/workflows/opengraph.yml

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: OpenGraph Service
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'packages/og-image/**'
8+
9+
workflow_dispatch:
10+
inputs:
11+
commit:
12+
required: false
13+
description: 'Commit ID'
14+
15+
jobs:
16+
deploy:
17+
name: Deploy to Cloudflare Workers
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: checkout
21+
uses: actions/checkout@v4
22+
with:
23+
fetch-depth: 0
24+
ref: ${{ env.COMMIT }}
25+
26+
- uses: the-guild-org/shared-config/setup@main
27+
name: setup env
28+
with:
29+
nodeVersion: 18
30+
packageManager: pnpm
31+
32+
- name: Deploy
33+
working-directory: ./packages/og-image
34+
run: pnpm run deploy
35+
env:
36+
CLOUDFLARE_API_TOKEN: ${{ secrets.GUILD_CLOUDFLARE_API_TOKEN }}
37+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.GUILD_CF_ACCOUNT_ID }}

.gitignore

+2-3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,5 @@ build/
3737
.eslintcache
3838
dist/
3939
.turbo/
40-
41-
.git
42-
.gitignore
40+
packages/og-image/vender/*.wasm
41+
.wrangler/

package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
"pre-commit": "lint-staged --concurrent false",
1717
"pre-push": "pnpm build",
1818
"prepare": "husky install && chmod +x .husky/*",
19-
"prettier": "prettier . --write --list-different",
20-
"prettier:check": "prettier . --check",
19+
"prettier": "pnpm prettier:check --write",
20+
"prettier:check": "prettier --cache --check .",
2121
"start": "pnpm --filter @graphprotocol/docs start",
22+
"test": "turbo run test",
2223
"typecheck": "turbo run typecheck"
2324
},
2425
"devDependencies": {

packages/nextra-theme/src/index.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ export default function NextraLayout({ children, pageOpts, pageProps }: NextraTh
132132
description: frontMatter.description,
133133
openGraph: {
134134
title,
135-
images: frontMatter.socialImage ? [{ url: frontMatter.socialImage }] : undefined,
135+
images: frontMatter.socialImage
136+
? [{ url: frontMatter.socialImage }]
137+
: [{ url: `https://thegraph-docs-opengraph-image.the-guild.dev?title=${title}` }],
136138
},
137139
}
138140
if (frontMatter.seo) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { handler } from '../src/handler'
2+
3+
vi.mock('../vender/index_bg.wasm', async () => {
4+
const fs = await import('node:fs/promises')
5+
const wasm = await fs.readFile(require.resolve('@resvg/resvg-wasm/index_bg.wasm'))
6+
return {
7+
default: wasm,
8+
}
9+
})
10+
11+
describe('handler()', () => {
12+
it('no title', async () => {
13+
const response = await handler({
14+
url: 'http://localhost:3000',
15+
} as Request)
16+
const result = Buffer.from(await response.arrayBuffer())
17+
expect(result).toMatchImageSnapshot()
18+
})
19+
it('should align title and have container padding', async () => {
20+
const response = await handler({
21+
url: 'http://localhost:3000?title=Hello this is a test of really really really really really really long title',
22+
} as Request)
23+
const result = Buffer.from(await response.arrayBuffer())
24+
expect(result).toMatchImageSnapshot()
25+
})
26+
it('should align title without whitespaces', async () => {
27+
const response = await handler({
28+
url: 'http://localhost:3000?title=Home',
29+
} as Request)
30+
const result = Buffer.from(await response.arrayBuffer())
31+
expect(result).toMatchImageSnapshot()
32+
})
33+
34+
describe('show individual languages', () => {
35+
for (const [lang, title] of Object.entries({
36+
ar: 'الأسئلة الشائعة حول الفرعيةرسم بياني استوديو',
37+
hi: 'फोर्क्स का उपयोग करके त्वरित और आसान सबग्राफ डिबगिंग',
38+
ja: 'フォークを用いた迅速かつ容易なサブグラフのデバッグ',
39+
ko: '다중서명 지갑 사용하기',
40+
ru: 'Замените контракт и сохраните его историю с помощью Grafting',
41+
ua: 'Мережа The Graph в порівнянні з Самостійним хостингом',
42+
ur: 'ایک معاہدے کو تبدیل کریں اور اس کی تاریخ کو گرافٹنگ کے ساتھ رکھیں',
43+
zh: '使用分叉快速轻松地调试子图',
44+
})) {
45+
it(lang, async () => {
46+
const response = await handler({
47+
url: `http://localhost:3000?title=${title}`,
48+
} as Request)
49+
const result = Buffer.from(await response.arrayBuffer())
50+
expect(result).toMatchImageSnapshot()
51+
})
52+
}
53+
})
54+
})
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
declare module 'vitest' {
2+
import type { Assertion, AsymmetricMatchersContaining } from 'vitest'
3+
4+
interface CustomMatchers<R = unknown> {
5+
toMatchImageSnapshot(): R
6+
}
7+
8+
interface Assertion<T = any> extends CustomMatchers<T> {}
9+
10+
interface AsymmetricMatchersContaining extends CustomMatchers {}
11+
}

packages/og-image/package.json

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@theguild/og-image",
3+
"version": "0.0.0",
4+
"type": "module",
5+
"private": true,
6+
"scripts": {
7+
"deploy": "wrangler publish",
8+
"postinstall": "tsx scripts/copy-wasm.ts",
9+
"start": "wrangler dev",
10+
"test": "vitest run"
11+
},
12+
"dependencies": {
13+
"@resvg/resvg-wasm": "2.4.1",
14+
"react": "18.2.0",
15+
"satori": "0.10.1",
16+
"yoga-wasm-web": "0.3.3"
17+
},
18+
"devDependencies": {
19+
"@cloudflare/workers-types": "^4.20230518.0",
20+
"@types/react": "^18.2.14",
21+
"jest-image-snapshot": "^6.1.0",
22+
"tsx": "^3.12.7",
23+
"typescript": "^5.1.5",
24+
"vitest": "^0.32.2",
25+
"wrangler": "^3.1.1"
26+
}
27+
}
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { readFile, writeFile } from 'node:fs/promises'
2+
import { createRequire } from 'node:module'
3+
import { join } from 'node:path'
4+
5+
const require = createRequire(import.meta.url)
6+
const __dirname = new URL('.', import.meta.url).pathname
7+
8+
await writeFile(
9+
join(__dirname, '../vender/index_bg.wasm'),
10+
await readFile(require.resolve('@resvg/resvg-wasm/index_bg.wasm')),
11+
)
12+
13+
// eslint-disable-next-line no-console
14+
console.log('✅ @resvg/resvg-wasm/index_bg.wasm copied!')

packages/og-image/setup-file.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { toMatchImageSnapshot } from 'jest-image-snapshot'
2+
3+
expect.extend({ toMatchImageSnapshot })

packages/og-image/src/handler.tsx

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* eslint react/no-unknown-property: ['error', { ignore: ['tw'] }] */
2+
import { toImage, toSVG } from './utils'
3+
4+
export async function handler(request: Request): Promise<Response> {
5+
try {
6+
const { searchParams } = new URL(request.url)
7+
// ?title=<title>
8+
const title = searchParams.get('title')?.slice(0, 100)
9+
10+
const rawSvg = await toSVG(
11+
<div
12+
tw="flex h-full flex-col w-full items-center justify-center text-white text-center p-10 pt-20"
13+
style={{
14+
backgroundImage: 'url(https://storage.googleapis.com/graph-website/seo/graph-website.jpg)',
15+
backgroundPosition: title ? '0 -70%' : '0 -55%',
16+
}}
17+
>
18+
{title && (
19+
// @ts-expect-error This isn't a valid CSS property supported by browsers yet.
20+
<span tw="text-5xl" style={{ textWrap: title.includes(' ') ? 'balance' : '' }}>
21+
{title}
22+
</span>
23+
)}
24+
</div>,
25+
)
26+
27+
const buffer = toImage(rawSvg)
28+
29+
return new Response(buffer, {
30+
headers: { 'Content-Type': 'image/png' },
31+
})
32+
} catch (e) {
33+
// eslint-disable-next-line no-console -- to debug
34+
console.error(e)
35+
return new Response(`Failed to generate the image.\n\nError: ${(e as Error).message}`, {
36+
status: 500,
37+
})
38+
}
39+
}

packages/og-image/src/index.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* eslint-disable import/no-default-export */
2+
/* eslint react/no-unknown-property: ['error', { ignore: ['tw'] }] */
3+
import { handler } from './handler'
4+
5+
const hour = 3600
6+
const day = hour * 24
7+
const year = 365 * day
8+
9+
const maxAgeForCDN = year
10+
const maxAgeForBrowser = hour / 2
11+
12+
export default {
13+
async fetch(request: Request, _env: unknown, ctx: ExecutionContext) {
14+
const cacheUrl = new URL(request.url)
15+
16+
// In case you want to purge the cache, please bump the version number below:
17+
cacheUrl.searchParams.set('version', 'v10')
18+
19+
// Construct the cache key from the cache URL
20+
const cacheKey = new Request(cacheUrl.toString(), request)
21+
const cache = caches.default
22+
23+
let response = await cache.match(cacheKey)
24+
25+
if (!response) {
26+
// If not in cache, get it from origin
27+
response = await handler(request)
28+
29+
if (process.env.NODE_ENV !== 'test' && ![404, 500].includes(response.status)) {
30+
// Any changes made to the response here will be reflected in the cached value
31+
response.headers.append('Cache-Control', 'public')
32+
response.headers.append('Cache-Control', `s-maxage=${maxAgeForCDN}`)
33+
response.headers.append('Cache-Control', `max-age=${maxAgeForBrowser}`)
34+
}
35+
36+
// Store the fetched response as cacheKey
37+
// Use `waitUntil`, so you can return the response without blocking on
38+
// writing to cache
39+
ctx.waitUntil(cache.put(cacheKey, response.clone()))
40+
}
41+
return response
42+
},
43+
}

packages/og-image/src/types.d.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
declare module '*.wasm' {
2+
export default ArrayBuffer
3+
}

packages/og-image/src/utils.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { initWasm, Resvg } from '@resvg/resvg-wasm'
2+
import { ReactNode } from 'react'
3+
import satori, { FontWeight } from 'satori'
4+
5+
import resvgWasm from '../vender/index_bg.wasm'
6+
7+
export function toImage(svg: string): Uint8Array {
8+
const resvg = new Resvg(svg)
9+
const pngData = resvg.render()
10+
return pngData.asPng()
11+
}
12+
13+
type Font = { data: ArrayBuffer; weight: FontWeight; name: string }
14+
15+
export async function loadGoogleFont({ family, weight }: { family: string; weight?: number }): Promise<Font> {
16+
const params: Record<string, string> = {
17+
family: `${family}${weight ? `:wght@${weight}` : ''}`,
18+
}
19+
20+
const url = `https://fonts.googleapis.com/css2?${new URLSearchParams(params)}`
21+
22+
const response = await fetch(url, {
23+
headers: {
24+
// construct user agent to get TTF font
25+
'User-Agent':
26+
'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1',
27+
},
28+
})
29+
const css = await response.text()
30+
// Get the font URL from the CSS text
31+
32+
const fontUrl = /src: url\((.+)\) format\('(opentype|truetype)'\)/.exec(css)?.[1]
33+
if (!fontUrl) {
34+
throw new Error('Could not find font URL')
35+
}
36+
37+
const res = await fetch(fontUrl)
38+
return {
39+
data: await res.arrayBuffer(),
40+
weight: Number(/weight: (.+);/.exec(css)?.[1]) as FontWeight,
41+
name: family,
42+
}
43+
}
44+
45+
let fonts: Font[]
46+
let init = false
47+
48+
export async function toSVG(node: ReactNode): Promise<string> {
49+
if (!init) {
50+
fonts = await Promise.all([
51+
loadGoogleFont({ family: 'Noto Sans', weight: 400 }),
52+
loadGoogleFont({ family: 'Noto Sans Arabic', weight: 400 }),
53+
// await loadGoogleFont({ family: 'Noto Sans JP', weight: 400 }),
54+
loadGoogleFont({ family: 'Noto Sans KR', weight: 400 }), // ko
55+
loadGoogleFont({ family: 'Noto Sans SC', weight: 400 }), // zh
56+
])
57+
await initWasm(resvgWasm)
58+
init = true
59+
}
60+
return satori(node, {
61+
width: 1200,
62+
height: 600,
63+
fonts,
64+
})
65+
}

packages/og-image/tsconfig.json

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2021",
4+
"lib": ["es2021"],
5+
"jsx": "react-jsx",
6+
"module": "es2022",
7+
"moduleResolution": "node",
8+
"types": ["vitest/globals", "@cloudflare/workers-types"],
9+
"resolveJsonModule": true,
10+
"allowJs": true,
11+
"checkJs": false,
12+
"noEmit": true,
13+
"isolatedModules": true,
14+
"allowSyntheticDefaultImports": true,
15+
"forceConsistentCasingInFileNames": true,
16+
"strict": true,
17+
"skipLibCheck": true
18+
}
19+
}

packages/og-image/vender/.gitkeep

Whitespace-only changes.

packages/og-image/vite.config.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defaultExclude, defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
globals: true,
6+
setupFiles: 'setup-file.ts',
7+
exclude: [...defaultExclude, '**/*.d.ts'],
8+
},
9+
})

packages/og-image/wrangler.toml

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
name = "graph-docs-opengraph-image"
2+
main = "src/index.ts"
3+
compatibility_date = "2022-10-07"
4+
compatibility_flags = ["streams_enable_constructors"]
5+
rules = [
6+
{ type = "Data", globs = ["**/*.ttf", "**/*.otf"], fallthrough = true },
7+
]

0 commit comments

Comments
 (0)