import { QuartzEmitterPlugin } from "../types" import { i18n } from "../../i18n" import { unescapeHTML } from "../../util/escape" import { FullSlug, getFileExtension, joinSegments, QUARTZ } from "../../util/path" import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" import sharp from "sharp" import satori, { SatoriOptions } from "satori" import { loadEmoji, getIconCode } from "../../util/emoji" import { Readable } from "stream" import { write } from "./helpers" import { BuildCtx } from "../../util/ctx" import { QuartzPluginData } from "../vfile" import fs from "node:fs/promises" import chalk from "chalk" const defaultOptions: SocialImageOptions = { colorScheme: "lightMode", width: 1200, height: 630, imageStructure: defaultImage, excludeRoot: false, } /** * Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder * @param opts options for generating image */ async function generateSocialImage( { cfg, description, fonts, title, fileData }: ImageOptions, userOpts: SocialImageOptions, ): Promise { const { width, height } = userOpts const iconPath = joinSegments(QUARTZ, "static", "icon.png") let iconBase64: string | undefined = undefined try { const iconData = await fs.readFile(iconPath) iconBase64 = `data:image/png;base64,${iconData.toString("base64")}` } catch (err) { console.warn(chalk.yellow(`Warning: Could not find icon at ${iconPath}`)) } const imageComponent = userOpts.imageStructure({ cfg, userOpts, title, description, fonts, fileData, iconBase64, }) const svg = await satori(imageComponent, { width, height, fonts, loadAdditionalAsset: async (languageCode: string, segment: string) => { if (languageCode === "emoji") { return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}` } return languageCode }, }) return sharp(Buffer.from(svg)).webp({ quality: 40 }) } async function processOgImage( ctx: BuildCtx, fileData: QuartzPluginData, fonts: SatoriOptions["fonts"], fullOptions: SocialImageOptions, ) { const cfg = ctx.cfg.configuration const slug = fileData.slug! const titleSuffix = cfg.pageTitleSuffix ?? "" const title = (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix const description = fileData.frontmatter?.socialDescription ?? fileData.frontmatter?.description ?? unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) const stream = await generateSocialImage( { title, description, fonts, cfg, fileData, }, fullOptions, ) return write({ ctx, content: stream, slug: `${slug}-og-image` as FullSlug, ext: ".webp", }) } export const CustomOgImagesEmitterName = "CustomOgImages" export const CustomOgImages: QuartzEmitterPlugin> = (userOpts) => { const fullOptions = { ...defaultOptions, ...userOpts } return { name: CustomOgImagesEmitterName, getQuartzComponents() { return [] }, async *emit(ctx, content, _resources) { const cfg = ctx.cfg.configuration const headerFont = cfg.theme.typography.header const bodyFont = cfg.theme.typography.body const fonts = await getSatoriFonts(headerFont, bodyFont) for (const [_tree, vfile] of content) { if (vfile.data.frontmatter?.socialImage !== undefined) continue yield processOgImage(ctx, vfile.data, fonts, fullOptions) } }, async *partialEmit(ctx, _content, _resources, changeEvents) { const cfg = ctx.cfg.configuration const headerFont = cfg.theme.typography.header const bodyFont = cfg.theme.typography.body const fonts = await getSatoriFonts(headerFont, bodyFont) // find all slugs that changed or were added for (const changeEvent of changeEvents) { if (!changeEvent.file) continue if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue if (changeEvent.type === "add" || changeEvent.type === "change") { yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions) } } }, externalResources: (ctx) => { if (!ctx.cfg.configuration.baseUrl) { return {} } const baseUrl = ctx.cfg.configuration.baseUrl return { additionalHead: [ (pageData) => { const isRealFile = pageData.filePath !== undefined const userDefinedOgImagePath = pageData.frontmatter?.socialImage const generatedOgImagePath = isRealFile ? `https://${baseUrl}/${pageData.slug!}-og-image.webp` : undefined const defaultOgImagePath = `https://${baseUrl}/static/og-image.png` const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}` return ( <> {!userDefinedOgImagePath && ( <> )} ) }, ], } }, } }