* checkpoint * make emitters async generators * fix * custom font spec * replace spinner, use disk cache for fonts * use readline instead * make og images look nice
		
			
				
	
	
		
			135 lines
		
	
	
		
			4.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			135 lines
		
	
	
		
			4.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import { QuartzEmitterPlugin } from "../types"
 | 
						|
import { i18n } from "../../i18n"
 | 
						|
import { unescapeHTML } from "../../util/escape"
 | 
						|
import { FullSlug, getFileExtension } from "../../util/path"
 | 
						|
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
 | 
						|
import sharp from "sharp"
 | 
						|
import satori from "satori"
 | 
						|
import { loadEmoji, getIconCode } from "../../util/emoji"
 | 
						|
import { Readable } from "stream"
 | 
						|
import { write } from "./helpers"
 | 
						|
 | 
						|
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 imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
 | 
						|
  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 })
 | 
						|
}
 | 
						|
 | 
						|
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 this file defines socialImage, we can skip
 | 
						|
        if (vfile.data.frontmatter?.socialImage !== undefined) {
 | 
						|
          continue
 | 
						|
        }
 | 
						|
 | 
						|
        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) => {
 | 
						|
      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 && (
 | 
						|
                  <>
 | 
						|
                    <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} />
 | 
						|
              </>
 | 
						|
            )
 | 
						|
          },
 | 
						|
        ],
 | 
						|
      }
 | 
						|
    },
 | 
						|
  }
 | 
						|
}
 |