feat: make og images an emitter to properly await image generation (#1826)

* checkpoint

* make emitters async generators

* fix

* custom font spec

* replace spinner, use disk cache for fonts

* use readline instead

* make og images look nice
This commit is contained in:
Jacky Zhao
2025-03-13 10:27:46 -07:00
committed by GitHub
parent c005fe4408
commit d9159e0ac9
30 changed files with 967 additions and 821 deletions

View File

@ -1,26 +1,43 @@
import { Spinner } from "cli-spinner"
import readline from "readline"
export class QuartzLogger {
verbose: boolean
spinner: Spinner | undefined
private spinnerInterval: NodeJS.Timeout | undefined
private spinnerText: string = ""
private spinnerIndex: number = 0
private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
constructor(verbose: boolean) {
this.verbose = verbose
}
start(text: string) {
this.spinnerText = text
if (this.verbose) {
console.log(text)
} else {
this.spinner = new Spinner(`%s ${text}`)
this.spinner.setSpinnerString(18)
this.spinner.start()
this.spinnerIndex = 0
this.spinnerInterval = setInterval(() => {
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`)
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
}, 100)
}
}
updateText(text: string) {
this.spinnerText = text
}
end(text?: string) {
if (!this.verbose) {
this.spinner!.stop(true)
if (!this.verbose && this.spinnerInterval) {
clearInterval(this.spinnerInterval)
this.spinnerInterval = undefined
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
}
if (text) {
console.log(text)
}

View File

@ -1,28 +1,55 @@
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, ThemeKey } from "./theme"
import path from "path"
import { QUARTZ } from "./path"
import { formatDate } from "../components/Date"
import { getDate } from "../components/Date"
/**
* 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]
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[]
// Fetch fonts
const headerFont = await fetchTtf(headerFontName, headerWeight)
const bodyFont = await fetchTtf(bodyFontName, bodyWeight)
const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
// Fetch fonts for all weights
const headerFontPromises = headerWeights.map((weight) => fetchTtf(headerFontName, weight))
const bodyFontPromises = bodyWeights.map((weight) => fetchTtf(bodyFontName, weight))
const [headerFontData, bodyFontData] = await Promise.all([
Promise.all(headerFontPromises),
Promise.all(bodyFontPromises),
])
// Convert fonts to satori font format and return
const fonts: SatoriOptions["fonts"] = [
{ name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" },
{ name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" },
...headerFontData.map((data, idx) => ({
name: headerFontName,
data,
weight: headerWeights[idx],
style: "normal" as const,
})),
...bodyFontData.map((data, idx) => ({
name: bodyFontName,
data,
weight: bodyWeights[idx],
style: "normal" as const,
})),
]
return fonts
}
@ -32,32 +59,49 @@ export async function getSatoriFont(headerFontName: string, bodyFontName: string
* @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(
fontName: string,
weight: FontWeight,
): Promise<Buffer<ArrayBufferLike>> {
const cacheKey = `${fontName.replaceAll(" ", "-")}-${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/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) {
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) {
throw new Error("Could not fetch font")
}
// fontData is an ArrayBuffer containing the .ttf file data
const fontResponse = await fetch(match[1])
const fontData = Buffer.from(await fontResponse.arrayBuffer())
try {
await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(cachePath, fontData)
} catch (error) {
console.warn(`Failed to cache font: ${error}`)
// Continue even if caching fails
}
return fontData
}
export type SocialImageOptions = {
@ -108,22 +152,10 @@ export type ImageOptions = {
* what description to use as body in image
*/
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)
*/
@ -141,68 +173,94 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
title: string,
description: string,
fonts: SatoriOptions["fonts"],
_fileData: QuartzPluginData,
fileData: QuartzPluginData,
) => {
const fontBreakPoint = 22
const fontBreakPoint = 32
const useSmallerFont = title.length > fontBreakPoint
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
// Get tags if available
const tags = fileData.frontmatter?.tags ?? []
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100%",
width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light,
gap: "2rem",
padding: "1.5rem 5rem",
padding: "2.5rem",
fontFamily: fonts[1].name,
}}
>
{/* Header Section */}
<div
style={{
display: "flex",
alignItems: "center",
width: "100%",
flexDirection: "row",
gap: "2.5rem",
gap: "1rem",
marginBottom: "0.5rem",
}}
>
<img src={iconPath} width={135} height={135} />
<img
src={iconPath}
width={56}
height={56}
style={{
borderRadius: "50%",
}}
/>
<div
style={{
display: "flex",
color: cfg.theme.colors[colorScheme].dark,
fontSize: useSmallerFont ? 70 : 82,
fontFamily: fonts[0].name,
maxWidth: "70%",
overflow: "hidden",
textOverflow: "ellipsis",
fontSize: 32,
color: cfg.theme.colors[colorScheme].gray,
fontFamily: fonts[1].name,
}}
>
<p
style={{
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</p>
{cfg.baseUrl}
</div>
</div>
{/* Title Section */}
<div
style={{
display: "flex",
color: cfg.theme.colors[colorScheme].dark,
fontSize: 44,
fontFamily: fonts[1].name,
maxWidth: "100%",
maxHeight: "40%",
overflow: "hidden",
marginTop: "1rem",
marginBottom: "1.5rem",
}}
>
<h1
style={{
margin: 0,
fontSize: useSmallerFont ? 64 : 72,
fontFamily: fonts[0].name,
fontWeight: 700,
color: cfg.theme.colors[colorScheme].dark,
lineHeight: 1.2,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
overflow: "hidden",
}}
>
{title}
</h1>
</div>
{/* Description Section */}
<div
style={{
display: "flex",
flex: 1,
fontSize: 36,
color: cfg.theme.colors[colorScheme].darkgray,
lineHeight: 1.4,
}}
>
<p
@ -210,14 +268,80 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
margin: 0,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 3,
WebkitLineClamp: 4,
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 */}
<div
style={{
display: "flex",
alignItems: "center",
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>
{/* 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>
)
}

View File

@ -36,7 +36,7 @@ export type RelativeURL = SlugLike<"relative">
export function isRelativeURL(s: string): s is RelativeURL {
const validStart = /^\.{1,2}/.test(s)
const validEnding = !endsWith(s, "index")
return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "")
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
}
export function getFullSlug(window: Window): FullSlug {
@ -61,7 +61,7 @@ function sluggify(s: string): string {
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
fp = stripSlashes(fp) as FilePath
let ext = _getFileExtension(fp)
let ext = getFileExtension(fp)
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
if (excludeExt || [".md", ".html", undefined].includes(ext)) {
ext = ""
@ -272,10 +272,10 @@ function containsForbiddenCharacters(s: string): boolean {
}
function _hasFileExtension(s: string): boolean {
return _getFileExtension(s) !== undefined
return getFileExtension(s) !== undefined
}
function _getFileExtension(s: string): string | undefined {
export function getFileExtension(s: string): string | undefined {
return s.match(/\.[A-Za-z0-9]+$/)?.[0]
}

View File

@ -15,7 +15,7 @@ interface Colors {
darkMode: ColorScheme
}
type FontSpecification =
export type FontSpecification =
| string
| {
name: string
@ -90,6 +90,36 @@ export function googleFontHref(theme: Theme) {
return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap`
}
export interface GoogleFontFile {
url: string
filename: string
extension: string
}
export async function processGoogleFonts(
stylesheet: string,
baseUrl: string,
): Promise<{
processedStylesheet: string
fontFiles: GoogleFontFile[]
}> {
const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g
const fontFiles: GoogleFontFile[] = []
let processedStylesheet = stylesheet
let match
while ((match = fontSourceRegex.exec(stylesheet)) !== null) {
const url = match[1]
const [filename, extension] = url.split("/").pop()!.split(".")
const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`
processedStylesheet = processedStylesheet.replace(url, staticUrl)
fontFiles.push({ url, filename, extension })
}
return { processedStylesheet, fontFiles }
}
export function joinStyles(theme: Theme, ...stylesheet: string[]) {
return `
${stylesheet.join("\n\n")}