| | |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import { i18n } from "../../i18n" |
| | | import { unescapeHTML } from "../../util/escape" |
| | | import { FullSlug, getFileExtension } from "../../util/path" |
| | | import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path" |
| | | import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" |
| | | import sharp from "sharp" |
| | | import satori from "satori" |
| | | 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 { styleText } from "util" |
| | | |
| | | const defaultOptions: SocialImageOptions = { |
| | | colorScheme: "lightMode", |
| | |
| | | userOpts: SocialImageOptions, |
| | | ): Promise<Readable> { |
| | | const { width, height } = userOpts |
| | | const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData) |
| | | 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(styleText("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 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<Partial<SocialImageOptions>> = (userOpts) => { |
| | | const fullOptions = { ...defaultOptions, ...userOpts } |
| | |
| | | const fonts = await getSatoriFonts(headerFont, bodyFont) |
| | | |
| | | for (const [_tree, vfile] of content) { |
| | | // if this file defines socialImage, we can skip |
| | | if (vfile.data.frontmatter?.socialImage !== undefined) { |
| | | continue |
| | | 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) |
| | | } |
| | | |
| | | const slug = vfile.data.slug! |
| | | const titleSuffix = cfg.pageTitleSuffix ?? "" |
| | | const title = |
| | | (vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix |
| | | const description = |
| | | vfile.data.frontmatter?.socialDescription ?? |
| | | vfile.data.frontmatter?.description ?? |
| | | unescapeHTML( |
| | | vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description, |
| | | ) |
| | | |
| | | const stream = await generateSocialImage( |
| | | { |
| | | title, |
| | | description, |
| | | fonts, |
| | | cfg, |
| | | fileData: vfile.data, |
| | | }, |
| | | fullOptions, |
| | | ) |
| | | |
| | | yield write({ |
| | | ctx, |
| | | content: stream, |
| | | slug: `${slug}-og-image` as FullSlug, |
| | | ext: ".webp", |
| | | }) |
| | | } |
| | | }, |
| | | externalResources: (ctx) => { |
| | |
| | | additionalHead: [ |
| | | (pageData) => { |
| | | const isRealFile = pageData.filePath !== undefined |
| | | const userDefinedOgImagePath = pageData.frontmatter?.socialImage |
| | | let userDefinedOgImagePath = pageData.frontmatter?.socialImage |
| | | |
| | | if (userDefinedOgImagePath) { |
| | | userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath) |
| | | ? userDefinedOgImagePath |
| | | : `https://${baseUrl}/static/${userDefinedOgImagePath}` |
| | | } |
| | | |
| | | 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 ( |
| | | <> |