* 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>
		
			
				
	
	
		
			182 lines
		
	
	
		
			5.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			182 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} />
 | |
|               </>
 | |
|             )
 | |
|           },
 | |
|         ],
 | |
|       }
 | |
|     },
 | |
|   }
 | |
| }
 |