Skip to content

Instantly share code, notes, and snippets.

@genox
Created May 29, 2023 18:24
Show Gist options
  • Save genox/13a2adb4202e35a8055a6990ff708de7 to your computer and use it in GitHub Desktop.
Save genox/13a2adb4202e35a8055a6990ff708de7 to your computer and use it in GitHub Desktop.
Using satori with qwik to emulate vercel/og
/**
* Modified version of https://unpkg.com/twemoji@13.1.0/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/twemoji@14.0.2/assets/svg/' + code.toLowerCase() + '.svg',
openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/',
blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/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`);
}
// 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);
}
};
// 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