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