From c538c151c7462ad0395ff2c15c5e11e89e362aa8 Mon Sep 17 00:00:00 2001
From: Striven <sg.striven@cutecat.club>
Date: Sat, 04 Apr 2026 19:47:16 +0000
Subject: [PATCH] Initial commit

---
 quartz/util/og.tsx |  374 +++++++++++++++++++++++++++++++++++++++--------------
 1 files changed, 276 insertions(+), 98 deletions(-)

diff --git a/quartz/util/og.tsx b/quartz/util/og.tsx
index 0430a26..2afd606 100644
--- a/quartz/util/og.tsx
+++ b/quartz/util/og.tsx
@@ -1,28 +1,67 @@
+import { promises as fs } from "fs"
 import { FontWeight, SatoriOptions } from "satori/wasm"
 import { GlobalConfiguration } from "../cfg"
 import { QuartzPluginData } from "../plugins/vfile"
 import { JSXInternal } from "preact/src/jsx"
-import { ThemeKey } from "./theme"
+import { FontSpecification, getFontSpecificationName, ThemeKey } from "./theme"
+import path from "path"
+import { QUARTZ } from "./path"
+import { formatDate, getDate } from "../components/Date"
+import readingTime from "reading-time"
+import { i18n } from "../i18n"
+import { styleText } from "util"
 
-/**
- * Get an array of `FontOptions` (for satori) given google font names
- * @param headerFontName name of google font used for header
- * @param bodyFontName name of google font used for body
- * @returns FontOptions for header and body
- */
-export async function getSatoriFont(headerFontName: string, bodyFontName: string) {
-  const headerWeight = 700 as FontWeight
-  const bodyWeight = 400 as FontWeight
+const defaultHeaderWeight = [700]
+const defaultBodyWeight = [400]
 
-  // Fetch fonts
-  const headerFont = await fetchTtf(headerFontName, headerWeight)
-  const bodyFont = await fetchTtf(bodyFontName, bodyWeight)
+export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
+  // Get all weights for header and body fonts
+  const headerWeights: FontWeight[] = (
+    typeof headerFont === "string"
+      ? defaultHeaderWeight
+      : (headerFont.weights ?? defaultHeaderWeight)
+  ) as FontWeight[]
+  const bodyWeights: FontWeight[] = (
+    typeof bodyFont === "string" ? defaultBodyWeight : (bodyFont.weights ?? defaultBodyWeight)
+  ) as FontWeight[]
 
-  // Convert fonts to satori font format and return
+  const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
+  const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
+
+  // Fetch fonts for all weights and convert to satori format in one go
+  const headerFontPromises = headerWeights.map(async (weight) => {
+    const data = await fetchTtf(headerFontName, weight)
+    if (!data) return null
+    return {
+      name: headerFontName,
+      data,
+      weight,
+      style: "normal" as const,
+    }
+  })
+
+  const bodyFontPromises = bodyWeights.map(async (weight) => {
+    const data = await fetchTtf(bodyFontName, weight)
+    if (!data) return null
+    return {
+      name: bodyFontName,
+      data,
+      weight,
+      style: "normal" as const,
+    }
+  })
+
+  const [headerFonts, bodyFonts] = await Promise.all([
+    Promise.all(headerFontPromises),
+    Promise.all(bodyFontPromises),
+  ])
+
+  // Filter out any failed fetches and combine header and body fonts
   const fonts: SatoriOptions["fonts"] = [
-    { name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" },
-    { name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" },
+    ...headerFonts.filter((font): font is NonNullable<typeof font> => font !== null),
+    ...bodyFonts.filter((font): font is NonNullable<typeof font> => font !== null),
   ]
+
   return fonts
 }
 
@@ -32,30 +71,50 @@
  * @param weight what font weight to fetch font
  * @returns `.ttf` file of google font
  */
-async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
+export async function fetchTtf(
+  rawFontName: string,
+  weight: FontWeight,
+): Promise<Buffer<ArrayBufferLike> | undefined> {
+  const fontName = rawFontName.replaceAll(" ", "+")
+  const cacheKey = `${fontName}-${weight}`
+  const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts")
+  const cachePath = path.join(cacheDir, cacheKey)
+
+  // Check if font exists in cache
   try {
-    // Get css file from google fonts
-    const cssResponse = await fetch(`https://fonts.googleapis.com/css?family=${fontName}:${weight}`)
-    const css = await cssResponse.text()
-
-    // Extract .ttf url from css file
-    const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
-    const match = urlRegex.exec(css)
-
-    if (!match) {
-      throw new Error("Could not fetch font")
-    }
-
-    // Retrieve font data as ArrayBuffer
-    const fontResponse = await fetch(match[1])
-
-    // fontData is an ArrayBuffer containing the .ttf file data (get match[1] due to google fonts response format, always contains link twice, but second entry is the "raw" link)
-    const fontData = await fontResponse.arrayBuffer()
-
-    return fontData
+    await fs.access(cachePath)
+    return fs.readFile(cachePath)
   } catch (error) {
-    throw new Error(`Error fetching font: ${error}`)
+    // ignore errors and fetch font
   }
+
+  // Get css file from google fonts
+  const cssResponse = await fetch(
+    `https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
+  )
+  const css = await cssResponse.text()
+
+  // Extract .ttf url from css file
+  const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
+  const match = urlRegex.exec(css)
+
+  if (!match) {
+    console.log(
+      styleText(
+        "yellow",
+        `\nWarning: Failed to fetch font ${rawFontName} with weight ${weight}, got ${cssResponse.statusText}`,
+      ),
+    )
+    return
+  }
+
+  // fontData is an ArrayBuffer containing the .ttf file data
+  const fontResponse = await fetch(match[1])
+  const fontData = Buffer.from(await fontResponse.arrayBuffer())
+  await fs.mkdir(cacheDir, { recursive: true })
+  await fs.writeFile(cachePath, fontData)
+
+  return fontData
 }
 
 export type SocialImageOptions = {
@@ -77,21 +136,12 @@
   excludeRoot: boolean
   /**
    * JSX to use for generating image. See satori docs for more info (https://github.com/vercel/satori)
-   * @param cfg global quartz config
-   * @param userOpts options that can be set by user
-   * @param title title of current page
-   * @param description description of current page
-   * @param fonts global font that can be used for styling
-   * @param fileData full fileData of current page
-   * @returns prepared jsx to be used for generating image
    */
   imageStructure: (
-    cfg: GlobalConfiguration,
-    userOpts: UserOpts,
-    title: string,
-    description: string,
-    fonts: SatoriOptions["fonts"],
-    fileData: QuartzPluginData,
+    options: ImageOptions & {
+      userOpts: UserOpts
+      iconBase64?: string
+    },
   ) => JSXInternal.Element
 }
 
@@ -107,21 +157,9 @@
    */
   description: string
   /**
-   * what fileName to use when writing to disk
-   */
-  fileName: string
-  /**
-   * what directory to store image in
-   */
-  fileDir: string
-  /**
-   * what file extension to use (should be `webp` unless you also change sharp conversion)
-   */
-  fileExt: string
-  /**
    * header + body font to be used when generating satori image (as promise to work around sync in component)
    */
-  fontsPromise: Promise<SatoriOptions["fonts"]>
+  fonts: SatoriOptions["fonts"]
   /**
    * `GlobalConfiguration` of quartz (used for theme/typography)
    */
@@ -133,68 +171,208 @@
 }
 
 // This is the default template for generated social image.
-export const defaultImage: SocialImageOptions["imageStructure"] = (
-  cfg: GlobalConfiguration,
-  { colorScheme }: UserOpts,
-  title: string,
-  description: string,
-  fonts: SatoriOptions["fonts"],
-  _fileData: QuartzPluginData,
-) => {
-  // How many characters are allowed before switching to smaller font
-  const fontBreakPoint = 22
+export const defaultImage: SocialImageOptions["imageStructure"] = ({
+  cfg,
+  userOpts,
+  title,
+  description,
+  fileData,
+  iconBase64,
+}) => {
+  const { colorScheme } = userOpts
+  const fontBreakPoint = 32
   const useSmallerFont = title.length > fontBreakPoint
 
-  // Setup to access image
-  const iconPath = `https://${cfg.baseUrl}/static/icon.png`
+  // Format date if available
+  const rawDate = getDate(cfg, fileData)
+  const date = rawDate ? formatDate(rawDate, cfg.locale) : null
+
+  // Calculate reading time
+  const { minutes } = readingTime(fileData.text ?? "")
+  const readingTimeText = i18n(cfg.locale).components.contentMeta.readingTime({
+    minutes: Math.ceil(minutes),
+  })
+
+  // Get tags if available
+  const tags = fileData.frontmatter?.tags ?? []
+  const bodyFont = getFontSpecificationName(cfg.theme.typography.body)
+  const headerFont = getFontSpecificationName(cfg.theme.typography.header)
+
   return (
     <div
       style={{
         display: "flex",
         flexDirection: "column",
-        justifyContent: "center",
-        alignItems: "center",
         height: "100%",
         width: "100%",
         backgroundColor: cfg.theme.colors[colorScheme].light,
-        gap: "2rem",
-        paddingTop: "1.5rem",
-        paddingBottom: "1.5rem",
-        paddingLeft: "5rem",
-        paddingRight: "5rem",
+        padding: "2.5rem",
+        fontFamily: bodyFont,
       }}
     >
+      {/* Header Section */}
       <div
         style={{
           display: "flex",
           alignItems: "center",
-          justifyContent: "flex-start",
-          width: "100%",
-          flexDirection: "row",
-          gap: "2.5rem",
+          gap: "1rem",
+          marginBottom: "0.5rem",
         }}
       >
-        <img src={iconPath} width={135} height={135} />
-        <p
+        {iconBase64 && (
+          <img
+            src={iconBase64}
+            width={56}
+            height={56}
+            style={{
+              borderRadius: "50%",
+            }}
+          />
+        )}
+        <div
           style={{
+            display: "flex",
+            fontSize: 32,
+            color: cfg.theme.colors[colorScheme].gray,
+            fontFamily: bodyFont,
+          }}
+        >
+          {cfg.baseUrl}
+        </div>
+      </div>
+
+      {/* Title Section */}
+      <div
+        style={{
+          display: "flex",
+          marginTop: "1rem",
+          marginBottom: "1.5rem",
+        }}
+      >
+        <h1
+          style={{
+            margin: 0,
+            fontSize: useSmallerFont ? 64 : 72,
+            fontFamily: headerFont,
+            fontWeight: 700,
             color: cfg.theme.colors[colorScheme].dark,
-            fontSize: useSmallerFont ? 70 : 82,
-            fontFamily: fonts[0].name,
+            lineHeight: 1.2,
+            display: "-webkit-box",
+            WebkitBoxOrient: "vertical",
+            WebkitLineClamp: 2,
+            overflow: "hidden",
+            textOverflow: "ellipsis",
           }}
         >
           {title}
-        </p>
+        </h1>
       </div>
-      <p
+
+      {/* Description Section */}
+      <div
         style={{
-          color: cfg.theme.colors[colorScheme].dark,
-          fontSize: 44,
-          lineClamp: 3,
-          fontFamily: fonts[1].name,
+          display: "flex",
+          flex: 1,
+          fontSize: 36,
+          color: cfg.theme.colors[colorScheme].darkgray,
+          lineHeight: 1.4,
         }}
       >
-        {description}
-      </p>
+        <p
+          style={{
+            margin: 0,
+            display: "-webkit-box",
+            WebkitBoxOrient: "vertical",
+            WebkitLineClamp: 5,
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+          }}
+        >
+          {description}
+        </p>
+      </div>
+
+      {/* Footer with Metadata */}
+      <div
+        style={{
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "space-between",
+          marginTop: "2rem",
+          paddingTop: "2rem",
+          borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`,
+        }}
+      >
+        {/* Left side - Date and Reading Time */}
+        <div
+          style={{
+            display: "flex",
+            alignItems: "center",
+            gap: "2rem",
+            color: cfg.theme.colors[colorScheme].gray,
+            fontSize: 28,
+          }}
+        >
+          {date && (
+            <div style={{ display: "flex", alignItems: "center" }}>
+              <svg
+                style={{ marginRight: "0.5rem" }}
+                width="28"
+                height="28"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+              >
+                <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
+                <line x1="16" y1="2" x2="16" y2="6"></line>
+                <line x1="8" y1="2" x2="8" y2="6"></line>
+                <line x1="3" y1="10" x2="21" y2="10"></line>
+              </svg>
+              {date}
+            </div>
+          )}
+          <div style={{ display: "flex", alignItems: "center" }}>
+            <svg
+              style={{ marginRight: "0.5rem" }}
+              width="28"
+              height="28"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+            >
+              <circle cx="12" cy="12" r="10"></circle>
+              <polyline points="12 6 12 12 16 14"></polyline>
+            </svg>
+            {readingTimeText}
+          </div>
+        </div>
+
+        {/* Right side - Tags */}
+        <div
+          style={{
+            display: "flex",
+            gap: "0.5rem",
+            flexWrap: "wrap",
+            justifyContent: "flex-end",
+            maxWidth: "60%",
+          }}
+        >
+          {tags.slice(0, 3).map((tag: string) => (
+            <div
+              style={{
+                display: "flex",
+                padding: "0.5rem 1rem",
+                backgroundColor: cfg.theme.colors[colorScheme].highlight,
+                color: cfg.theme.colors[colorScheme].secondary,
+                borderRadius: "10px",
+                fontSize: 24,
+              }}
+            >
+              #{tag}
+            </div>
+          ))}
+        </div>
+      </div>
     </div>
   )
 }

--
Gitblit v1.10.0