* fix(ogImage): update socialImage path to include base URL if defined * feat(path): add function to check if a file path is absolute * fix(ogImage): handle absolute paths for user defined og image paths * docs(CustomOgImages): update socialImage property to accept full URLs * fix(ogImage): typo * fix(ogImage): improve user-defined OG image path handling * Update docs/plugins/CustomOgImages.md Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com> * Update quartz/plugins/emitters/ogImage.tsx Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com> * refactor(path): remove isAbsoluteFilePath function * fix(ogImage): update user-defined OG image path handling to support relative URLs * feat(ogImage): enhance user-defined OG image path handling with absolute URL support * refactor(ogImage): remove debug log for ogImagePath * feat(path): add isAbsoluteURL function and corresponding tests * refactor(path): remove unused URL import for isomorphic compatibility --------- Co-authored-by: Karim H <karimh96@hotmail.com> Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
181 lines
5.9 KiB
TypeScript
181 lines
5.9 KiB
TypeScript
import { QuartzEmitterPlugin } from "../types"
|
|
import { i18n } from "../../i18n"
|
|
import { unescapeHTML } from "../../util/escape"
|
|
import { FullSlug, getFileExtension, isAbsoluteURL, 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<Readable> {
|
|
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<Partial<SocialImageOptions>> = (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
|
|
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 (
|
|
<>
|
|
{!userDefinedOgImagePath && (
|
|
<>
|
|
<meta property="og:image:width" content={fullOptions.width.toString()} />
|
|
<meta property="og:image:height" content={fullOptions.height.toString()} />
|
|
</>
|
|
)}
|
|
|
|
<meta property="og:image" content={ogImagePath} />
|
|
<meta property="og:image:url" content={ogImagePath} />
|
|
<meta name="twitter:image" content={ogImagePath} />
|
|
<meta property="og:image:type" content={ogImageMimeType} />
|
|
</>
|
|
)
|
|
},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
}
|