* checkpoint * make emitters async generators * fix * custom font spec * replace spinner, use disk cache for fonts * use readline instead * make og images look nice
347 lines
9.6 KiB
TypeScript
347 lines
9.6 KiB
TypeScript
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"
|
|
import { formatDate } from "../components/Date"
|
|
import { getDate } from "../components/Date"
|
|
|
|
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[]
|
|
|
|
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"] = [
|
|
...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
|
|
}
|
|
|
|
/**
|
|
* 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<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
|
|
}
|
|
|
|
// 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 = {
|
|
/**
|
|
* What color scheme to use for image generation (uses colors from config theme)
|
|
*/
|
|
colorScheme: ThemeKey
|
|
/**
|
|
* Height to generate image with in pixels (should be around 630px)
|
|
*/
|
|
height: number
|
|
/**
|
|
* Width to generate image with in pixels (should be around 1200px)
|
|
*/
|
|
width: number
|
|
/**
|
|
* Whether to use the auto generated image for the root path ("/", when set to false) or the default og image (when set to true).
|
|
*/
|
|
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,
|
|
) => JSXInternal.Element
|
|
}
|
|
|
|
export type UserOpts = Omit<SocialImageOptions, "imageStructure">
|
|
|
|
export type ImageOptions = {
|
|
/**
|
|
* what title to use as header in image
|
|
*/
|
|
title: string
|
|
/**
|
|
* what description to use as body in image
|
|
*/
|
|
description: string
|
|
/**
|
|
* header + body font to be used when generating satori image (as promise to work around sync in component)
|
|
*/
|
|
fonts: SatoriOptions["fonts"]
|
|
/**
|
|
* `GlobalConfiguration` of quartz (used for theme/typography)
|
|
*/
|
|
cfg: GlobalConfiguration
|
|
/**
|
|
* full file data of current page
|
|
*/
|
|
fileData: QuartzPluginData
|
|
}
|
|
|
|
// 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,
|
|
) => {
|
|
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",
|
|
height: "100%",
|
|
width: "100%",
|
|
backgroundColor: cfg.theme.colors[colorScheme].light,
|
|
padding: "2.5rem",
|
|
fontFamily: fonts[1].name,
|
|
}}
|
|
>
|
|
{/* Header Section */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "1rem",
|
|
marginBottom: "0.5rem",
|
|
}}
|
|
>
|
|
<img
|
|
src={iconPath}
|
|
width={56}
|
|
height={56}
|
|
style={{
|
|
borderRadius: "50%",
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
fontSize: 32,
|
|
color: cfg.theme.colors[colorScheme].gray,
|
|
fontFamily: fonts[1].name,
|
|
}}
|
|
>
|
|
{cfg.baseUrl}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Title Section */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
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
|
|
style={{
|
|
margin: 0,
|
|
display: "-webkit-box",
|
|
WebkitBoxOrient: "vertical",
|
|
WebkitLineClamp: 4,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{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>
|
|
)
|
|
}
|