| | |
| | | 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 |
| | | } |
| | | |
| | |
| | | * @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/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) { |
| | | 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 = { |
| | |
| | | */ |
| | | 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) |
| | | */ |
| | |
| | | title: string, |
| | | description: string, |
| | | fonts: SatoriOptions["fonts"], |
| | | _fileData: QuartzPluginData, |
| | | fileData: QuartzPluginData, |
| | | ) => { |
| | | // How many characters are allowed before switching to smaller font |
| | | const fontBreakPoint = 22 |
| | | 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 |
| | | |
| | | // 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", |
| | | paddingTop: "1.5rem", |
| | | paddingBottom: "1.5rem", |
| | | paddingLeft: "5rem", |
| | | paddingRight: "5rem", |
| | | padding: "2.5rem", |
| | | fontFamily: fonts[1].name, |
| | | }} |
| | | > |
| | | {/* 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 |
| | | <img |
| | | src={iconPath} |
| | | width={56} |
| | | height={56} |
| | | style={{ |
| | | color: cfg.theme.colors[colorScheme].dark, |
| | | fontSize: useSmallerFont ? 70 : 82, |
| | | 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} |
| | | </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: 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> |
| | | ) |
| | | } |