replace spinner, use disk cache for fonts

This commit is contained in:
Jacky Zhao 2025-03-13 08:38:16 -07:00
parent f301eca9a7
commit 07ffc8681e
7 changed files with 90 additions and 66 deletions

10
package-lock.json generated
View File

@ -75,7 +75,6 @@
"quartz": "quartz/bootstrap-cli.mjs"
},
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
@ -1585,15 +1584,6 @@
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@types/cli-spinner": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@types/cli-spinner/-/cli-spinner-0.2.3.tgz",
"integrity": "sha512-TMO6mWltW0lCu1de8DMRq9+59OP/tEjghS+rs8ZEQ2EgYP5yV3bGw0tS14TMyJGqFaoVChNvhkVzv9RC1UgX+w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",

View File

@ -98,7 +98,6 @@
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",

View File

@ -87,6 +87,7 @@ const config: QuartzConfig = {
Plugin.Assets(),
Plugin.Static(),
Plugin.NotFoundPage(),
Plugin.CustomOgImages(),
],
},
}

View File

@ -3,7 +3,6 @@ import { i18n } from "../../i18n"
import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import { getFontSpecificationName } from "../../util/theme"
import sharp from "sharp"
import satori from "satori"
import { loadEmoji, getIconCode } from "../../util/emoji"

View File

@ -4,6 +4,7 @@ import { ProcessedContent } from "../plugins/vfile"
import { QuartzLogger } from "../util/log"
import { trace } from "../util/trace"
import { BuildCtx } from "../util/ctx"
import chalk from "chalk"
export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const { argv, cfg } = ctx
@ -24,14 +25,18 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
emittedFiles++
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`)
} else {
log.updateText(`Emitting output files: ${chalk.gray(file)}`)
}
}
} else {
// Array case
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`)
} else {
log.updateText(`Emitting output files: ${chalk.gray(file)}`)
}
}
}

View File

@ -1,26 +1,41 @@
import { Spinner } from "cli-spinner"
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(() => {
process.stdout.clearLine(0)
process.stdout.cursorTo(0)
process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`)
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
}, 100)
}
}
end(text?: string) {
if (!this.verbose) {
this.spinner!.stop(true)
updateText(text: string) {
this.spinnerText = text
}
end(text?: string) {
if (!this.verbose && this.spinnerInterval) {
clearInterval(this.spinnerInterval)
this.spinnerInterval = undefined
process.stdout.clearLine(0)
process.stdout.cursorTo(0)
}
if (text) {
console.log(text)
}

View File

@ -1,8 +1,11 @@
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 { FontSpecification, ThemeKey } from "./theme"
import path from "path"
import { QUARTZ } from "./path"
const defaultHeaderWeight = [700]
const defaultBodyWeight = [400]
@ -48,24 +51,28 @@ export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: Fo
return fonts
}
// Cache for memoizing font data
const fontCache = new Map<string, Promise<ArrayBuffer>>()
/**
* Get the `.ttf` file of a google font
* @param fontName name of google font
* @param weight what font weight to fetch font
* @returns `.ttf` file of google font
*/
export async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
const cacheKey = `${fontName}-${weight}`
if (fontCache.has(cacheKey)) {
return fontCache.get(cacheKey)!
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 {
await fs.access(cachePath)
return fs.readFile(cachePath)
} catch (error) {
// ignore errors and fetch font
}
// If not in cache, fetch and store the promise
const fontPromise = (async () => {
try {
// Get css file from google fonts
const cssResponse = await fetch(
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
@ -80,16 +87,19 @@ export async function fetchTtf(fontName: string, weight: FontWeight): Promise<Ar
throw new Error("Could not fetch font")
}
// 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)
// fontData is an ArrayBuffer containing the .ttf file data
const fontResponse = await fetch(match[1])
return await fontResponse.arrayBuffer()
} catch (error) {
throw new Error(`Error fetching font: ${error}`)
}
})()
const fontData = Buffer.from(await fontResponse.arrayBuffer())
fontCache.set(cacheKey, fontPromise)
return fontPromise
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 = {
@ -161,7 +171,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
title: string,
description: string,
fonts: SatoriOptions["fonts"],
_fileData: QuartzPluginData,
fileData: QuartzPluginData,
) => {
const fontBreakPoint = 22
const useSmallerFont = title.length > fontBreakPoint
@ -177,8 +187,8 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
height: "100%",
width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light,
gap: "2rem",
padding: "1.5rem 5rem",
gap: "1rem",
padding: "3rem 3rem",
}}
>
<div
@ -187,31 +197,36 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
alignItems: "center",
width: "100%",
flexDirection: "row",
gap: "2.5rem",
gap: "2rem",
}}
>
<div
style={{
display: "flex",
border: "1px solid red",
}}
>
<img src={iconPath} width={135} height={135} />
</div>
<div
style={{
display: "flex",
color: cfg.theme.colors[colorScheme].dark,
fontSize: useSmallerFont ? 70 : 82,
fontFamily: fonts[0].name,
maxWidth: "70%",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "80%",
}}
>
<p
<h1
style={{
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: useSmallerFont ? 64 : 72,
fontFamily: fonts[0].name,
}}
>
{title}
</p>
</h1>
</div>
</div>
<div
@ -221,7 +236,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
fontSize: 44,
fontFamily: fonts[1].name,
maxWidth: "100%",
maxHeight: "40%",
maxHeight: "60%",
overflow: "hidden",
}}
>
@ -230,7 +245,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
margin: 0,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 3,
WebkitLineClamp: 5,
overflow: "hidden",
textOverflow: "ellipsis",
}}