Compare commits

...

5 Commits

Author SHA1 Message Date
Jacky Zhao
3173d185ed make og images look nice 2025-03-13 10:17:19 -07:00
Jacky Zhao
de727b4686 use readline instead 2025-03-13 08:46:55 -07:00
Jacky Zhao
07ffc8681e replace spinner, use disk cache for fonts 2025-03-13 08:38:16 -07:00
Jacky Zhao
f301eca9a7 custom font spec 2025-03-12 22:56:59 -07:00
Jacky Zhao
1fb7756c49 fix 2025-03-12 22:26:39 -07:00
10 changed files with 298 additions and 138 deletions

View File

@@ -107,27 +107,35 @@ export const myImage: SocialImageOptions["imageStructure"] = (...) => {
> import fs from "fs" > import fs from "fs"
> import path from "path" > import path from "path"
> >
> const headerFont = joinSegments(QUARTZ, "static", "Newsreader.woff2") > const newsreaderFontPath = joinSegments(QUARTZ, "static", "Newsreader.woff2")
> const bodyFont = joinSegments(QUARTZ, "static", "Newsreader.woff2") > export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
> > // ... rest of implementation remains same
> export async function getSatoriFont(cfg: GlobalConfiguration): Promise<SatoriOptions["fonts"]> { > const fonts: SatoriOptions["fonts"] = [
> const headerWeight: FontWeight = 700 > ...headerFontData.map((data, idx) => ({
> const bodyWeight: FontWeight = 400 > name: headerFontName,
> > data,
> const [header, body] = await Promise.all( > weight: headerWeights[idx],
> [headerFont, bodyFont].map((font) => > style: "normal" as const,
> fs.promises.readFile(path.resolve(font)) > })),
> ), > ...bodyFontData.map((data, idx) => ({
> ) > name: bodyFontName,
> > data,
> return [ > weight: bodyWeights[idx],
> { name: cfg.theme.typography.header, data: header, weight: headerWeight, style: "normal" }, > style: "normal" as const,
> { name: cfg.theme.typography.body, data: body, weight: bodyWeight, style: "normal" }, > })),
> {
> name: "Newsreader",
> data: await fs.promises.readFile(path.resolve(newsreaderFontPath)),
> weight: 400,
> style: "normal" as const,
> },
> ] > ]
>
> return fonts
> } > }
> ``` > ```
> >
> This font then can be used with your custom structure > This font then can be used with your custom structure.
## Examples ## Examples

10
package-lock.json generated
View File

@@ -75,7 +75,6 @@
"quartz": "quartz/bootstrap-cli.mjs" "quartz": "quartz/bootstrap-cli.mjs"
}, },
"devDependencies": { "devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
@@ -1585,15 +1584,6 @@
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT" "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": { "node_modules/@types/css-font-loading-module": {
"version": "0.0.12", "version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", "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" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",

View File

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

View File

@@ -250,15 +250,25 @@ async function partialRebuildFromEntrypoint(
([_node, vfile]) => !toRemove.has(vfile.data.filePath!), ([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
) )
const emittedFps = await emitter.emit(ctx, files, staticResources) const emitted = await emitter.emit(ctx, files, staticResources)
if (Symbol.asyncIterator in emitted) {
// Async generator case
for await (const file of emitted) {
emittedFiles++
if (ctx.argv.verbose) { if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} }
} }
} else {
// Array case
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
}
emittedFiles += emittedFps.length
continue continue
} }
@@ -280,15 +290,24 @@ async function partialRebuildFromEntrypoint(
.filter((file) => !toRemove.has(file)) .filter((file) => !toRemove.has(file))
.map((file) => contentMap.get(file)!) .map((file) => contentMap.get(file)!)
const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources) const emitted = await emitter.emit(ctx, upstreamContent, staticResources)
if (Symbol.asyncIterator in emitted) {
// Async generator case
for await (const file of emitted) {
emittedFiles++
if (ctx.argv.verbose) { if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} }
} }
} else {
emittedFiles += emittedFps.length // Array case
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
}
} }
} }

View File

@@ -2,8 +2,7 @@ import { QuartzEmitterPlugin } from "../types"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { unescapeHTML } from "../../util/escape" import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension } from "../../util/path" import { FullSlug, getFileExtension } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFont } from "../../util/og" import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import { getFontSpecificationName } from "../../util/theme"
import sharp from "sharp" import sharp from "sharp"
import satori from "satori" import satori from "satori"
import { loadEmoji, getIconCode } from "../../util/emoji" import { loadEmoji, getIconCode } from "../../util/emoji"
@@ -54,9 +53,9 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
}, },
async *emit(ctx, content, _resources) { async *emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const headerFont = getFontSpecificationName(cfg.theme.typography.header) const headerFont = cfg.theme.typography.header
const bodyFont = getFontSpecificationName(cfg.theme.typography.body) const bodyFont = cfg.theme.typography.body
const fonts = await getSatoriFont(headerFont, bodyFont) const fonts = await getSatoriFonts(headerFont, bodyFont)
for (const [_tree, vfile] of content) { for (const [_tree, vfile] of content) {
// if this file defines socialImage, we can skip // if this file defines socialImage, we can skip

View File

@@ -4,6 +4,7 @@ import { ProcessedContent } from "../plugins/vfile"
import { QuartzLogger } from "../util/log" import { QuartzLogger } from "../util/log"
import { trace } from "../util/trace" import { trace } from "../util/trace"
import { BuildCtx } from "../util/ctx" import { BuildCtx } from "../util/ctx"
import chalk from "chalk"
export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) { export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const { argv, cfg } = ctx const { argv, cfg } = ctx
@@ -20,20 +21,22 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const emitted = await emitter.emit(ctx, content, staticResources) const emitted = await emitter.emit(ctx, content, staticResources)
if (Symbol.asyncIterator in emitted) { if (Symbol.asyncIterator in emitted) {
// Async generator case // Async generator case
const files: string[] = []
for await (const file of emitted) { for await (const file of emitted) {
files.push(file)
emittedFiles++ emittedFiles++
if (ctx.argv.verbose) { if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} else {
log.updateText(`Emitting output files: ${chalk.gray(file)}`)
} }
} }
} else { } else {
// Array case // Array case
emittedFiles += emitted.length emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) { for (const file of emitted) {
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} else {
log.updateText(`Emitting output files: ${chalk.gray(file)}`)
} }
} }
} }

View File

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

View File

@@ -1,51 +1,80 @@
import { promises as fs } from "fs"
import { FontWeight, SatoriOptions } from "satori/wasm" import { FontWeight, SatoriOptions } from "satori/wasm"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { JSXInternal } from "preact/src/jsx" 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"
/** const defaultHeaderWeight = [700]
* Get an array of `FontOptions` (for satori) given google font names const defaultBodyWeight = [400]
* @param headerFontName name of google font used for header export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
* @param bodyFontName name of google font used for body // Get all weights for header and body fonts
* @returns FontOptions for header and body const headerWeights: FontWeight[] = (
*/ typeof headerFont === "string"
export async function getSatoriFont(headerFontName: string, bodyFontName: string) { ? defaultHeaderWeight
const headerWeight = 700 as FontWeight : (headerFont.weights ?? defaultHeaderWeight)
const bodyWeight = 400 as FontWeight ) as FontWeight[]
const bodyWeights: FontWeight[] = (
typeof bodyFont === "string" ? defaultBodyWeight : (bodyFont.weights ?? defaultBodyWeight)
) as FontWeight[]
// Fetch fonts const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
const [headerFont, bodyFont] = await Promise.all([ const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
fetchTtf(headerFontName, headerWeight),
fetchTtf(bodyFontName, bodyWeight), // 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 // Convert fonts to satori font format and return
const fonts: SatoriOptions["fonts"] = [ const fonts: SatoriOptions["fonts"] = [
{ name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" }, ...headerFontData.map((data, idx) => ({
{ name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" }, 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 return fonts
} }
// Cache for memoizing font data
const fontCache = new Map<string, Promise<ArrayBuffer>>()
/** /**
* Get the `.ttf` file of a google font * Get the `.ttf` file of a google font
* @param fontName name of google font * @param fontName name of google font
* @param weight what font weight to fetch font * @param weight what font weight to fetch font
* @returns `.ttf` file of google font * @returns `.ttf` file of google font
*/ */
export async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> { export async function fetchTtf(
const cacheKey = `${fontName}-${weight}` fontName: string,
if (fontCache.has(cacheKey)) { weight: FontWeight,
return fontCache.get(cacheKey)! ): 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 // Get css file from google fonts
const cssResponse = await fetch( const cssResponse = await fetch(
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`, `https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
@@ -60,16 +89,19 @@ export async function fetchTtf(fontName: string, weight: FontWeight): Promise<Ar
throw new Error("Could not fetch font") 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]) const fontResponse = await fetch(match[1])
return await fontResponse.arrayBuffer() const fontData = Buffer.from(await fontResponse.arrayBuffer())
} catch (error) {
throw new Error(`Error fetching font: ${error}`)
}
})()
fontCache.set(cacheKey, fontPromise) try {
return fontPromise 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 = { export type SocialImageOptions = {
@@ -141,68 +173,94 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
title: string, title: string,
description: string, description: string,
fonts: SatoriOptions["fonts"], fonts: SatoriOptions["fonts"],
_fileData: QuartzPluginData, fileData: QuartzPluginData,
) => { ) => {
const fontBreakPoint = 22 const fontBreakPoint = 32
const useSmallerFont = title.length > fontBreakPoint const useSmallerFont = title.length > fontBreakPoint
const iconPath = `https://${cfg.baseUrl}/static/icon.png` 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 ( return (
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100%", height: "100%",
width: "100%", width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light, backgroundColor: cfg.theme.colors[colorScheme].light,
gap: "2rem", padding: "2.5rem",
padding: "1.5rem 5rem", fontFamily: fonts[1].name,
}} }}
> >
{/* Header Section */}
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
width: "100%", gap: "1rem",
flexDirection: "row", marginBottom: "0.5rem",
gap: "2.5rem",
}} }}
> >
<img src={iconPath} width={135} height={135} /> <img
src={iconPath}
width={56}
height={56}
style={{
borderRadius: "50%",
}}
/>
<div <div
style={{ style={{
display: "flex", display: "flex",
color: cfg.theme.colors[colorScheme].dark, fontSize: 32,
fontSize: useSmallerFont ? 70 : 82, color: cfg.theme.colors[colorScheme].gray,
fontFamily: fonts[0].name, fontFamily: fonts[1].name,
maxWidth: "70%",
overflow: "hidden",
textOverflow: "ellipsis",
}} }}
> >
<p {cfg.baseUrl}
</div>
</div>
{/* Title Section */}
<div
style={{
display: "flex",
marginTop: "1rem",
marginBottom: "1.5rem",
}}
>
<h1
style={{ style={{
margin: 0, 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", overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}} }}
> >
{title} {title}
</p> </h1>
</div>
</div> </div>
{/* Description Section */}
<div <div
style={{ style={{
display: "flex", display: "flex",
color: cfg.theme.colors[colorScheme].dark, flex: 1,
fontSize: 44, fontSize: 36,
fontFamily: fonts[1].name, color: cfg.theme.colors[colorScheme].darkgray,
maxWidth: "100%", lineHeight: 1.4,
maxHeight: "40%",
overflow: "hidden",
}} }}
> >
<p <p
@@ -210,14 +268,80 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
margin: 0, margin: 0,
display: "-webkit-box", display: "-webkit-box",
WebkitBoxOrient: "vertical", WebkitBoxOrient: "vertical",
WebkitLineClamp: 3, WebkitLineClamp: 4,
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis",
}} }}
> >
{description} {description}
</p> </p>
</div> </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> </div>
) )
} }

View File

@@ -15,7 +15,7 @@ interface Colors {
darkMode: ColorScheme darkMode: ColorScheme
} }
type FontSpecification = export type FontSpecification =
| string | string
| { | {
name: string name: string