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, 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" 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 const headerFontPromises = headerWeights.map((weight) => fetchTtf(headerFontName, weight)) const bodyFontPromises = bodyWeights.map((weight) => fetchTtf(bodyFontName, weight)) const [headerFontData, bodyFontData] = await Promise.all([ Promise.all(headerFontPromises), Promise.all(bodyFontPromises), ]) // Convert fonts to satori font format and return const fonts: SatoriOptions["fonts"] = [ ...headerFontData.map((data, idx) => ({ name: headerFontName, data, weight: headerWeights[idx], style: "normal" as const, })), ...bodyFontData.map((data, idx) => ({ name: bodyFontName, data, weight: bodyWeights[idx], style: "normal" as const, })), ] 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( fontName: string, weight: FontWeight, ): Promise<Buffer<ArrayBufferLike>> { const cacheKey = `${fontName.replaceAll(" ", "-")}-${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) { throw new Error("Could not fetch font") } // fontData is an ArrayBuffer containing the .ttf file data const fontResponse = await fetch(match[1]) const fontData = Buffer.from(await fontResponse.arrayBuffer()) try { await fs.mkdir(cacheDir, { recursive: true }) await fs.writeFile(cachePath, fontData) } catch (error) { console.warn(`Failed to cache font: ${error}`) // Continue even if caching fails } 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) * @param cfg global quartz config * @param userOpts options that can be set by user * @param title title of current page * @param description description of current page * @param fonts global font that can be used for styling * @param fileData full fileData of current page * @returns prepared jsx to be used for generating image */ imageStructure: ( cfg: GlobalConfiguration, userOpts: UserOpts, title: string, description: string, fonts: SatoriOptions["fonts"], fileData: QuartzPluginData, ) => JSXInternal.Element } export type UserOpts = Omit<SocialImageOptions, "imageStructure"> 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: GlobalConfiguration, { colorScheme }: UserOpts, title: string, description: string, fonts: SatoriOptions["fonts"], fileData: QuartzPluginData, ) => { const fontBreakPoint = 32 const useSmallerFont = title.length > fontBreakPoint const iconPath = `https://${cfg.baseUrl}/static/icon.png` // 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 ?? [] return ( <div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%", backgroundColor: cfg.theme.colors[colorScheme].light, padding: "2.5rem", fontFamily: fonts[1].name, }} > {/* Header Section */} <div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "0.5rem", }} > <img src={iconPath} width={56} height={56} style={{ borderRadius: "50%", }} /> <div style={{ display: "flex", fontSize: 32, color: cfg.theme.colors[colorScheme].gray, fontFamily: fonts[1].name, }} > {cfg.baseUrl} </div> </div> {/* Title Section */} <div style={{ display: "flex", marginTop: "1rem", marginBottom: "1.5rem", }} > <h1 style={{ margin: 0, fontSize: useSmallerFont ? 64 : 72, fontFamily: fonts[0].name, fontWeight: 700, color: cfg.theme.colors[colorScheme].dark, lineHeight: 1.2, display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 2, overflow: "hidden", }} > {title} </h1> </div> {/* Description Section */} <div style={{ display: "flex", flex: 1, fontSize: 36, color: cfg.theme.colors[colorScheme].darkgray, lineHeight: 1.4, }} > <p style={{ margin: 0, display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 4, overflow: "hidden", }} > {description} </p> </div> {/* Footer with Metadata */} <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "2rem", paddingTop: "2rem", borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`, }} > {/* Left side - Date and Reading Time */} <div style={{ display: "flex", alignItems: "center", gap: "2rem", color: cfg.theme.colors[colorScheme].gray, fontSize: 28, }} > {date && ( <div style={{ display: "flex", alignItems: "center" }}> <svg style={{ marginRight: "0.5rem" }} width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" > <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> <line x1="16" y1="2" x2="16" y2="6"></line> <line x1="8" y1="2" x2="8" y2="6"></line> <line x1="3" y1="10" x2="21" y2="10"></line> </svg> {date} </div> )} <div style={{ display: "flex", alignItems: "center" }}> <svg style={{ marginRight: "0.5rem" }} width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" > <circle cx="12" cy="12" r="10"></circle> <polyline points="12 6 12 12 16 14"></polyline> </svg> {readingTimeText} </div> </div> {/* Right side - Tags */} <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", justifyContent: "flex-end", maxWidth: "60%", }} > {tags.slice(0, 3).map((tag: string) => ( <div style={{ display: "flex", padding: "0.5rem 1rem", backgroundColor: cfg.theme.colors[colorScheme].highlight, color: cfg.theme.colors[colorScheme].secondary, borderRadius: "10px", fontSize: 24, }} > #{tag} </div> ))} </div> </div> </div> ) }