Created
May 29, 2023 18:24
-
-
Save genox/13a2adb4202e35a8055a6990ff708de7 to your computer and use it in GitHub Desktop.
Using satori with qwik to emulate vercel/og
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Modified version of https://unpkg.com/[email protected]/dist/twemoji.esm.js. | |
*/ | |
/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ | |
const U200D = String.fromCharCode(8205); // zero-width joiner | |
const UFE0Fg = /\uFE0F/g; // variation selector regex | |
export function getIconCode(char: string) { | |
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, '') : char); | |
} | |
function toCodePoint(unicodeSurrogates: string) { | |
let r: string[] = [], | |
c = 0, | |
p = 0, | |
i = 0; | |
while (i < unicodeSurrogates.length) { | |
c = unicodeSurrogates.charCodeAt(i++); | |
if (p) { | |
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16)); | |
p = 0; | |
} else if (55296 <= c && c <= 56319) { | |
p = c; | |
} else { | |
r.push(c.toString(16)); | |
} | |
} | |
return r.join('-'); | |
} | |
const apis = { | |
twemoji: (code: string) => | |
'https://cdn.jsdelivr.net/gh/twitter/[email protected]/assets/svg/' + code.toLowerCase() + '.svg', | |
openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/', | |
blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/', | |
noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/', | |
fluent: (code: string) => | |
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' + | |
code.toLowerCase() + | |
'_color.svg', | |
fluentFlat: (code: string) => | |
'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' + | |
code.toLowerCase() + | |
'_flat.svg', | |
}; | |
export type EmojiType = keyof typeof apis; | |
export function loadEmoji(code: string, type: EmojiType) { | |
// https://github.com/svgmoji/svgmoji | |
const api = apis[type] ?? apis.twemoji; | |
if (typeof api === 'function') { | |
return fetch(api(code)); | |
} | |
return fetch(`${api}${code.toUpperCase()}.svg`); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// the route, e.g. routes/api/icon/index.ts | |
/** @jsxImportSource react */ | |
import { ImageResponse } from '~/utils/og/og'; | |
import { ogLogoSymbol } from '~/routes/api/icon/ogLogoSymbol'; | |
import type { RequestHandler } from '@builder.io/qwik-city'; | |
export const onRequest: RequestHandler = async ({ status, send, url }) => { | |
try { | |
const { searchParams } = new URL(url); | |
// these values come from the original svg file. height auto is not supported by the og:image generator | |
const aspectRatio = 207 / 107; | |
// ?size=<size> | |
const hasSize = searchParams.has('size'); | |
const size = hasSize ? parseInt(searchParams.get('size')?.slice(0, 4) || '16', 10) : 16; | |
const response = new ImageResponse( | |
( | |
<div | |
style={{ | |
backgroundColor: '#000000', | |
color: '#ffffff', | |
height: '100%', | |
width: '100%', | |
display: 'flex', | |
textAlign: 'center', | |
alignItems: 'center', | |
justifyContent: 'center', | |
flexDirection: 'column', | |
flexWrap: 'nowrap', | |
}}> | |
<div | |
style={{ | |
display: 'flex', | |
alignItems: 'center', | |
justifyContent: 'center', | |
justifyItems: 'center', | |
padding: 4, | |
}}> | |
<img width={size - 4} height={size / aspectRatio - 4} src={ogLogoSymbol} /> | |
</div> | |
</div> | |
), | |
{ | |
width: size, | |
height: size, | |
} | |
); | |
send(response as Response); | |
} catch (e: any) { | |
console.log(`${e.message}`); | |
status(500); | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// copied from https://github.com/m5r/og | |
// utils/og/og.ts | |
import fs from 'node:fs/promises'; | |
import type { SatoriOptions } from 'satori'; | |
import { renderAsync } from '@resvg/resvg-js'; | |
import { type EmojiType, getIconCode, loadEmoji } from './emoji'; | |
const satoriImport = import('satori'); | |
const fallbackFont = fs.readFile('./assets/fonts/noto-sans-v27-latin-regular.ttf'); | |
const isDev = process.env.NODE_ENV === 'development'; | |
const languageFontMap = { | |
'ja-JP': 'Noto+Sans+JP', | |
'ko-KR': 'Noto+Sans+KR', | |
'zh-CN': 'Noto+Sans+SC', | |
'zh-TW': 'Noto+Sans+TC', | |
'zh-HK': 'Noto+Sans+HK', | |
'th-TH': 'Noto+Sans+Thai', | |
'bn-IN': 'Noto+Sans+Bengali', | |
'ar-AR': 'Noto+Sans+Arabic', | |
'ta-IN': 'Noto+Sans+Tamil', | |
'ml-IN': 'Noto+Sans+Malayalam', | |
'he-IL': 'Noto+Sans+Hebrew', | |
'te-IN': 'Noto+Sans+Telugu', | |
devanagari: 'Noto+Sans+Devanagari', | |
kannada: 'Noto+Sans+Kannada', | |
symbol: ['Noto+Sans+Symbols', 'Noto+Sans+Symbols+2'], | |
math: 'Noto+Sans+Math', | |
unknown: 'Noto+Sans', | |
}; | |
async function loadGoogleFont(fontFamily: string | string[], text: string) { | |
if (!fontFamily || !text) { | |
return; | |
} | |
const API = `https://fonts.googleapis.com/css2?family=${fontFamily}&text=${encodeURIComponent( | |
text | |
)}`; | |
const css = await ( | |
await fetch(API, { | |
headers: { | |
// Make sure it returns TTF. | |
'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', | |
}, | |
}) | |
).text(); | |
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/); | |
if (!resource) { | |
throw new Error('Failed to load font'); | |
} | |
return fetch(resource[1]).then((res) => res.arrayBuffer()); | |
} | |
const assetCache = new Map(); | |
const loadDynamicAsset = (emojiType: EmojiType = 'twemoji') => { | |
const fn = async (languageCode: string, text: string) => { | |
if (languageCode === 'emoji') { | |
// It's an emoji, load the image. | |
return ( | |
'data:image/svg+xml;base64,' + | |
btoa(await (await loadEmoji(getIconCode(text), emojiType)).text()) | |
); | |
} | |
// Try to load from Google Fonts. | |
if (!Object.hasOwn(languageFontMap, languageCode)) { | |
languageCode = 'unknown'; | |
} | |
try { | |
const fontData = await loadGoogleFont( | |
languageFontMap[languageCode as keyof typeof languageFontMap], | |
text | |
); | |
if (fontData) { | |
return { | |
name: `satori_${languageCode}_fallback_${text}`, | |
data: fontData, | |
weight: 400, | |
style: 'normal', | |
}; | |
} | |
} catch (error) { | |
console.error('Failed to load dynamic font for', text, '. Error:', error); | |
} | |
}; | |
return async (...args: Parameters<typeof fn>) => { | |
const cacheKey = JSON.stringify({ ...args, emojiType }); | |
const cachedFont = assetCache.get(cacheKey); | |
if (cachedFont) { | |
return cachedFont; | |
} | |
const font = await fn(...args); | |
assetCache.set(cacheKey, font); | |
return font; | |
}; | |
}; | |
export declare type ImageResponseOptions = ConstructorParameters<typeof Response>[1] & { | |
/** | |
* The width of the image. | |
* | |
* @type {number} | |
* @default 1200 | |
*/ | |
width?: number; | |
/** | |
* The height of the image. | |
* | |
* @type {number} | |
* @default 630 | |
*/ | |
height?: number; | |
/** | |
* Display debug information on the image. | |
* | |
* @type {boolean} | |
* @default false | |
*/ | |
debug?: boolean; | |
/** | |
* A list of fonts to use. | |
* | |
* @type {{ data: ArrayBuffer; name: string; weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; style?: 'normal' | 'italic' }[]} | |
* @default Noto Sans Latin Regular. | |
*/ | |
fonts?: SatoriOptions['fonts']; | |
/** | |
* Using a specific Emoji style. Defaults to `twemoji`. | |
* | |
* @link https://github.com/vercel/og#emoji | |
* @type {EmojiType} | |
* @default 'twemoji' | |
*/ | |
emoji?: EmojiType; | |
}; | |
export class ImageResponse { | |
// Todo: element was ReactElement, but we don't have ReactElement in this project. Satori relies on this Type though. | |
constructor(element: any, options: ImageResponseOptions = {}) { | |
const extendedOptions = Object.assign( | |
{ | |
width: 1200, | |
height: 630, | |
debug: false, | |
}, | |
options | |
); | |
const stream = new ReadableStream({ | |
async start(controller) { | |
const fontData = await fallbackFont; | |
const { default: satori } = await satoriImport; | |
const svg = await satori(element, { | |
width: extendedOptions.width, | |
height: extendedOptions.height, | |
debug: extendedOptions.debug, | |
fonts: extendedOptions.fonts || [ | |
{ | |
name: 'sans serif', | |
data: fontData, | |
weight: 700, | |
style: 'normal', | |
}, | |
], | |
loadAdditionalAsset: loadDynamicAsset(extendedOptions.emoji), | |
}); | |
const image = await renderAsync(svg, { | |
fitTo: { | |
mode: 'width', | |
value: extendedOptions.width, | |
}, | |
}); | |
controller.enqueue(image.asPng()); | |
controller.close(); | |
}, | |
}); | |
return new Response(stream, { | |
headers: { | |
'content-type': 'image/png', | |
'cache-control': isDev | |
? 'no-cache, no-store' | |
: 'public, immutable, no-transform, max-age=31536000', | |
...extendedOptions.headers, | |
}, | |
status: extendedOptions.status, | |
statusText: extendedOptions.statusText, | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment