mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-22 05:55:42 -05:00
* Initial plan * Break component-emitter coupling by introducing shared-types module Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Decouple transformer from component scripts via resource registry Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Document plugin data dependencies with @plugin, @reads, @writes annotations Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Address code review feedback: improve docs and exhaustiveness checking Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Add exhaustiveness checking with unreachable assertions Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Fix getComponentJS return type and remove unnecessary null checks Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>
192 lines
6.1 KiB
TypeScript
192 lines
6.1 KiB
TypeScript
import { QuartzEmitterPlugin } from "../types"
|
|
import { i18n } from "../../i18n"
|
|
import { FullSlug } 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 { styleText } from "util"
|
|
import { PluginUtilities } from "../plugin-context"
|
|
import { CustomOgImagesEmitterName } from "../shared-types"
|
|
|
|
// Re-export for backward compatibility
|
|
export { CustomOgImagesEmitterName }
|
|
|
|
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,
|
|
utils: PluginUtilities,
|
|
): Promise<Readable> {
|
|
const { width, height } = userOpts
|
|
const iconPath = utils.path.join(utils.path.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 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 { utils } = ctx
|
|
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 ??
|
|
utils!.escape.unescape(
|
|
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description,
|
|
)
|
|
|
|
const stream = await generateSocialImage(
|
|
{
|
|
title,
|
|
description,
|
|
fonts,
|
|
cfg,
|
|
fileData,
|
|
},
|
|
fullOptions,
|
|
utils!,
|
|
)
|
|
|
|
return write({
|
|
ctx,
|
|
content: stream,
|
|
slug: `${slug}-og-image` as FullSlug,
|
|
ext: ".webp",
|
|
})
|
|
}
|
|
|
|
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) => {
|
|
const { utils } = 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 = utils!.path.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/${utils!.path.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} />
|
|
</>
|
|
)
|
|
},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
}
|