Skip to content

Commit

Permalink
add open graph images + multi langs + image snapshots tests (#431)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
Dimitri POSTOLOV and saihaj authored Oct 13, 2023
1 parent 9f7e9e0 commit dba64ed
Show file tree
Hide file tree
Showing 32 changed files with 3,135 additions and 108 deletions.
37 changes: 37 additions & 0 deletions .github/workflows/opengraph.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: OpenGraph Service

on:
push:
branches: [main]
paths:
- 'packages/og-image/**'

workflow_dispatch:
inputs:
commit:
required: false
description: 'Commit ID'

jobs:
deploy:
name: Deploy to Cloudflare Workers
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.COMMIT }}

- uses: the-guild-org/shared-config/setup@main
name: setup env
with:
nodeVersion: 18
packageManager: pnpm

- name: Deploy
working-directory: ./packages/og-image
run: pnpm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.GUILD_CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.GUILD_CF_ACCOUNT_ID }}
5 changes: 2 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,5 @@ build/
.eslintcache
dist/
.turbo/

.git
.gitignore
packages/og-image/vender/*.wasm
.wrangler/
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
"pre-commit": "lint-staged --concurrent false",
"pre-push": "pnpm build",
"prepare": "husky install && chmod +x .husky/*",
"prettier": "prettier . --write --list-different",
"prettier:check": "prettier . --check",
"prettier": "pnpm prettier:check --write",
"prettier:check": "prettier --cache --check .",
"start": "pnpm --filter @graphprotocol/docs start",
"test": "turbo run test",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion packages/nextra-theme/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ export default function NextraLayout({ children, pageOpts, pageProps }: NextraTh
description: frontMatter.description,
openGraph: {
title,
images: frontMatter.socialImage ? [{ url: frontMatter.socialImage }] : undefined,
images: frontMatter.socialImage
? [{ url: frontMatter.socialImage }]
: [{ url: `https://thegraph-docs-opengraph-image.the-guild.dev?title=${title}` }],
},
}
if (frontMatter.seo) {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions packages/og-image/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { handler } from '../src/handler'

vi.mock('../vender/index_bg.wasm', async () => {
const fs = await import('node:fs/promises')
const wasm = await fs.readFile(require.resolve('@resvg/resvg-wasm/index_bg.wasm'))
return {
default: wasm,
}
})

describe('handler()', () => {
it('no title', async () => {
const response = await handler({
url: 'http://localhost:3000',
} as Request)
const result = Buffer.from(await response.arrayBuffer())
expect(result).toMatchImageSnapshot()
})
it('should align title and have container padding', async () => {
const response = await handler({
url: 'http://localhost:3000?title=Hello this is a test of really really really really really really long title',
} as Request)
const result = Buffer.from(await response.arrayBuffer())
expect(result).toMatchImageSnapshot()
})
it('should align title without whitespaces', async () => {
const response = await handler({
url: 'http://localhost:3000?title=Home',
} as Request)
const result = Buffer.from(await response.arrayBuffer())
expect(result).toMatchImageSnapshot()
})

describe('show individual languages', () => {
for (const [lang, title] of Object.entries({
ar: 'الأسئلة الشائعة حول الفرعيةرسم بياني استوديو',
hi: 'फोर्क्स का उपयोग करके त्वरित और आसान सबग्राफ डिबगिंग',
ja: 'フォークを用いた迅速かつ容易なサブグラフのデバッグ',
ko: '다중서명 지갑 사용하기',
ru: 'Замените контракт и сохраните его историю с помощью Grafting',
ua: 'Мережа The Graph в порівнянні з Самостійним хостингом',
ur: 'ایک معاہدے کو تبدیل کریں اور اس کی تاریخ کو گرافٹنگ کے ساتھ رکھیں',
zh: '使用分叉快速轻松地调试子图',
})) {
it(lang, async () => {
const response = await handler({
url: `http://localhost:3000?title=${title}`,
} as Request)
const result = Buffer.from(await response.arrayBuffer())
expect(result).toMatchImageSnapshot()
})
}
})
})
11 changes: 11 additions & 0 deletions packages/og-image/__tests__/vitest.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
declare module 'vitest' {
import type { Assertion, AsymmetricMatchersContaining } from 'vitest'

interface CustomMatchers<R = unknown> {
toMatchImageSnapshot(): R
}

interface Assertion<T = any> extends CustomMatchers<T> {}

interface AsymmetricMatchersContaining extends CustomMatchers {}
}
27 changes: 27 additions & 0 deletions packages/og-image/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@theguild/og-image",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"deploy": "wrangler publish",
"postinstall": "tsx scripts/copy-wasm.ts",
"start": "wrangler dev",
"test": "vitest run"
},
"dependencies": {
"@resvg/resvg-wasm": "2.4.1",
"react": "18.2.0",
"satori": "0.10.1",
"yoga-wasm-web": "0.3.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230518.0",
"@types/react": "^18.2.14",
"jest-image-snapshot": "^6.1.0",
"tsx": "^3.12.7",
"typescript": "^5.1.5",
"vitest": "^0.32.2",
"wrangler": "^3.1.1"
}
}
14 changes: 14 additions & 0 deletions packages/og-image/scripts/copy-wasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join } from 'node:path'

const require = createRequire(import.meta.url)
const __dirname = new URL('.', import.meta.url).pathname

await writeFile(
join(__dirname, '../vender/index_bg.wasm'),
await readFile(require.resolve('@resvg/resvg-wasm/index_bg.wasm')),
)

// eslint-disable-next-line no-console
console.log('✅ @resvg/resvg-wasm/index_bg.wasm copied!')
3 changes: 3 additions & 0 deletions packages/og-image/setup-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { toMatchImageSnapshot } from 'jest-image-snapshot'

expect.extend({ toMatchImageSnapshot })
39 changes: 39 additions & 0 deletions packages/og-image/src/handler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint react/no-unknown-property: ['error', { ignore: ['tw'] }] */
import { toImage, toSVG } from './utils'

export async function handler(request: Request): Promise<Response> {
try {
const { searchParams } = new URL(request.url)
// ?title=<title>
const title = searchParams.get('title')?.slice(0, 100)

const rawSvg = await toSVG(
<div
tw="flex h-full flex-col w-full items-center justify-center text-white text-center p-10 pt-20"
style={{
backgroundImage: 'url(https://storage.googleapis.com/graph-website/seo/graph-website.jpg)',
backgroundPosition: title ? '0 -70%' : '0 -55%',
}}
>
{title && (
// @ts-expect-error This isn't a valid CSS property supported by browsers yet.
<span tw="text-5xl" style={{ textWrap: title.includes(' ') ? 'balance' : '' }}>
{title}
</span>
)}
</div>,
)

const buffer = toImage(rawSvg)

return new Response(buffer, {
headers: { 'Content-Type': 'image/png' },
})
} catch (e) {
// eslint-disable-next-line no-console -- to debug
console.error(e)
return new Response(`Failed to generate the image.\n\nError: ${(e as Error).message}`, {
status: 500,
})
}
}
43 changes: 43 additions & 0 deletions packages/og-image/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* eslint-disable import/no-default-export */
/* eslint react/no-unknown-property: ['error', { ignore: ['tw'] }] */
import { handler } from './handler'

const hour = 3600
const day = hour * 24
const year = 365 * day

const maxAgeForCDN = year
const maxAgeForBrowser = hour / 2

export default {
async fetch(request: Request, _env: unknown, ctx: ExecutionContext) {
const cacheUrl = new URL(request.url)

// In case you want to purge the cache, please bump the version number below:
cacheUrl.searchParams.set('version', 'v10')

// Construct the cache key from the cache URL
const cacheKey = new Request(cacheUrl.toString(), request)
const cache = caches.default

let response = await cache.match(cacheKey)

if (!response) {
// If not in cache, get it from origin
response = await handler(request)

if (process.env.NODE_ENV !== 'test' && ![404, 500].includes(response.status)) {
// Any changes made to the response here will be reflected in the cached value
response.headers.append('Cache-Control', 'public')
response.headers.append('Cache-Control', `s-maxage=${maxAgeForCDN}`)
response.headers.append('Cache-Control', `max-age=${maxAgeForBrowser}`)
}

// Store the fetched response as cacheKey
// Use `waitUntil`, so you can return the response without blocking on
// writing to cache
ctx.waitUntil(cache.put(cacheKey, response.clone()))
}
return response
},
}
3 changes: 3 additions & 0 deletions packages/og-image/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module '*.wasm' {
export default ArrayBuffer
}
65 changes: 65 additions & 0 deletions packages/og-image/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { initWasm, Resvg } from '@resvg/resvg-wasm'
import { ReactNode } from 'react'
import satori, { FontWeight } from 'satori'

import resvgWasm from '../vender/index_bg.wasm'

export function toImage(svg: string): Uint8Array {
const resvg = new Resvg(svg)
const pngData = resvg.render()
return pngData.asPng()
}

type Font = { data: ArrayBuffer; weight: FontWeight; name: string }

export async function loadGoogleFont({ family, weight }: { family: string; weight?: number }): Promise<Font> {
const params: Record<string, string> = {
family: `${family}${weight ? `:wght@${weight}` : ''}`,
}

const url = `https://fonts.googleapis.com/css2?${new URLSearchParams(params)}`

const response = await fetch(url, {
headers: {
// construct user agent to get TTF font
'User-Agent':
'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',
},
})
const css = await response.text()
// Get the font URL from the CSS text

const fontUrl = /src: url\((.+)\) format\('(opentype|truetype)'\)/.exec(css)?.[1]
if (!fontUrl) {
throw new Error('Could not find font URL')
}

const res = await fetch(fontUrl)
return {
data: await res.arrayBuffer(),
weight: Number(/weight: (.+);/.exec(css)?.[1]) as FontWeight,
name: family,
}
}

let fonts: Font[]
let init = false

export async function toSVG(node: ReactNode): Promise<string> {
if (!init) {
fonts = await Promise.all([
loadGoogleFont({ family: 'Noto Sans', weight: 400 }),
loadGoogleFont({ family: 'Noto Sans Arabic', weight: 400 }),
// await loadGoogleFont({ family: 'Noto Sans JP', weight: 400 }),
loadGoogleFont({ family: 'Noto Sans KR', weight: 400 }), // ko
loadGoogleFont({ family: 'Noto Sans SC', weight: 400 }), // zh
])
await initWasm(resvgWasm)
init = true
}
return satori(node, {
width: 1200,
height: 600,
fonts,
})
}
19 changes: 19 additions & 0 deletions packages/og-image/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["es2021"],
"jsx": "react-jsx",
"module": "es2022",
"moduleResolution": "node",
"types": ["vitest/globals", "@cloudflare/workers-types"],
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
Empty file.
9 changes: 9 additions & 0 deletions packages/og-image/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defaultExclude, defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
setupFiles: 'setup-file.ts',
exclude: [...defaultExclude, '**/*.d.ts'],
},
})
7 changes: 7 additions & 0 deletions packages/og-image/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name = "graph-docs-opengraph-image"
main = "src/index.ts"
compatibility_date = "2022-10-07"
compatibility_flags = ["streams_enable_constructors"]
rules = [
{ type = "Data", globs = ["**/*.ttf", "**/*.otf"], fallthrough = true },
]
Loading

0 comments on commit dba64ed

Please sign in to comment.