import { promises as fs } from "fs" import { FontWeight, SatoriOptions } from "satori/wasm" import { GlobalConfiguration } from "../cfg" import { QuartzPluginData } from "../plugins/vfile" import { JSXInternal } from "preact/src/jsx" import { FontSpecification, getFontSpecificationName, ThemeKey } from "./theme" import path from "path" import { QUARTZ } from "./path" import { formatDate, getDate } from "../components/Date" import readingTime from "reading-time" import { i18n } from "../i18n" import chalk from "chalk" const defaultHeaderWeight = [700] const defaultBodyWeight = [400] export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) { // Get all weights for header and body fonts const headerWeights: FontWeight[] = ( typeof headerFont === "string" ? defaultHeaderWeight : (headerFont.weights ?? defaultHeaderWeight) ) as FontWeight[] const bodyWeights: FontWeight[] = ( typeof bodyFont === "string" ? defaultBodyWeight : (bodyFont.weights ?? defaultBodyWeight) ) as FontWeight[] const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name // Fetch fonts for all weights and convert to satori format in one go const headerFontPromises = headerWeights.map(async (weight) => { const data = await fetchTtf(headerFontName, weight) if (!data) return null return { name: headerFontName, data, weight, style: "normal" as const, } }) const bodyFontPromises = bodyWeights.map(async (weight) => { const data = await fetchTtf(bodyFontName, weight) if (!data) return null return { name: bodyFontName, data, weight, style: "normal" as const, } }) const [headerFonts, bodyFonts] = await Promise.all([ Promise.all(headerFontPromises), Promise.all(bodyFontPromises), ]) // Filter out any failed fetches and combine header and body fonts const fonts: SatoriOptions["fonts"] = [ ...headerFonts.filter((font): font is NonNullable => font !== null), ...bodyFonts.filter((font): font is NonNullable => font !== null), ] return fonts } /** * Get the `.ttf` file of a google font * @param fontName name of google font * @param weight what font weight to fetch font * @returns `.ttf` file of google font */ export async function fetchTtf( rawFontName: string, weight: FontWeight, ): Promise | undefined> { const fontName = rawFontName.replaceAll(" ", "+") const cacheKey = `${fontName}-${weight}` const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts") const cachePath = path.join(cacheDir, cacheKey) // Check if font exists in cache try { await fs.access(cachePath) return fs.readFile(cachePath) } catch (error) { // ignore errors and fetch font } // Get css file from google fonts const cssResponse = await fetch( `https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`, ) const css = await cssResponse.text() // Extract .ttf url from css file const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g const match = urlRegex.exec(css) if (!match) { console.log( chalk.yellow( `\nWarning: Failed to fetch font ${rawFontName} with weight ${weight}, got ${cssResponse.statusText}`, ), ) return } // fontData is an ArrayBuffer containing the .ttf file data const fontResponse = await fetch(match[1]) const fontData = Buffer.from(await fontResponse.arrayBuffer()) await fs.mkdir(cacheDir, { recursive: true }) await fs.writeFile(cachePath, fontData) return fontData } export type SocialImageOptions = { /** * What color scheme to use for image generation (uses colors from config theme) */ colorScheme: ThemeKey /** * Height to generate image with in pixels (should be around 630px) */ height: number /** * Width to generate image with in pixels (should be around 1200px) */ width: number /** * Whether to use the auto generated image for the root path ("/", when set to false) or the default og image (when set to true). */ excludeRoot: boolean /** * JSX to use for generating image. See satori docs for more info (https://github.com/vercel/satori) */ imageStructure: ( options: ImageOptions & { userOpts: UserOpts iconBase64?: string }, ) => JSXInternal.Element } export type UserOpts = Omit export type ImageOptions = { /** * what title to use as header in image */ title: string /** * what description to use as body in image */ description: string /** * header + body font to be used when generating satori image (as promise to work around sync in component) */ fonts: SatoriOptions["fonts"] /** * `GlobalConfiguration` of quartz (used for theme/typography) */ cfg: GlobalConfiguration /** * full file data of current page */ fileData: QuartzPluginData } // This is the default template for generated social image. export const defaultImage: SocialImageOptions["imageStructure"] = ({ cfg, userOpts, title, description, fileData, iconBase64, }) => { const { colorScheme } = userOpts const fontBreakPoint = 32 const useSmallerFont = title.length > fontBreakPoint // Format date if available const rawDate = getDate(cfg, fileData) const date = rawDate ? formatDate(rawDate, cfg.locale) : null // Calculate reading time const { minutes } = readingTime(fileData.text ?? "") const readingTimeText = i18n(cfg.locale).components.contentMeta.readingTime({ minutes: Math.ceil(minutes), }) // Get tags if available const tags = fileData.frontmatter?.tags ?? [] const bodyFont = getFontSpecificationName(cfg.theme.typography.body) const headerFont = getFontSpecificationName(cfg.theme.typography.header) return (
{/* Header Section */}
{iconBase64 && ( )}
{cfg.baseUrl}
{/* Title Section */}

{title}

{/* Description Section */}

{description}

{/* Footer with Metadata */}
{/* Left side - Date and Reading Time */}
{date && (
{date}
)}
{readingTimeText}
{/* Right side - Tags */}
{tags.slice(0, 3).map((tag: string) => (
#{tag}
))}
) }