split index.ts and og.ts
This commit is contained in:
parent
75c5fc0841
commit
16139c3bcc
@ -56,7 +56,9 @@
|
|||||||
"splitting": false,
|
"splitting": false,
|
||||||
"sourcemap": true,
|
"sourcemap": true,
|
||||||
"target": "node18",
|
"target": "node18",
|
||||||
"dts": true,
|
"dts": {
|
||||||
|
"entry": ["src/emoji.ts", "src/og.ts", "src/index.ts"]
|
||||||
|
},
|
||||||
"clean": true,
|
"clean": true,
|
||||||
"minify": true
|
"minify": true
|
||||||
},
|
},
|
||||||
|
182
src/index.ts
182
src/index.ts
@ -1,181 +1 @@
|
|||||||
import fs from "node:fs/promises";
|
export * from "./og";
|
||||||
import path from "node:path";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
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(path.resolve(__dirname, "../vendor/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: {
|
|
||||||
"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) => {
|
|
||||||
const loadDynamicFont = 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
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 loadDynamicFont>) => {
|
|
||||||
const cacheKey = JSON.stringify(args);
|
|
||||||
const cachedFont = assetCache.get(cacheKey);
|
|
||||||
if (cachedFont) {
|
|
||||||
return cachedFont;
|
|
||||||
}
|
|
||||||
|
|
||||||
const font = await loadDynamicFont(...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 {
|
|
||||||
constructor(element: ReactElement, 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
180
src/og.ts
Normal file
180
src/og.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
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(path.resolve(__dirname, "../vendor/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);
|
||||||
|
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 {
|
||||||
|
constructor(element: ReactElement, 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user