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

@ -31,7 +31,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
async getDependencyGraph(_ctx, _content, _resources) {
return new DepGraph<FilePath>()
},
async emit(ctx, _content, resources): Promise<FilePath[]> {
async *emit(ctx, _content, resources) {
const cfg = ctx.cfg.configuration
const slug = "404" as FullSlug
@ -55,14 +55,12 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
allFiles: [],
}
return [
await write({
ctx,
content: renderPage(cfg, slug, componentData, opts, externalResources),
slug,
ext: ".html",
}),
]
yield write({
ctx,
content: renderPage(cfg, slug, componentData, opts, externalResources),
slug,
ext: ".html",
})
},
}
}

View File

@ -18,15 +18,13 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
return graph
},
async emit(ctx, content, _resources): Promise<FilePath[]> {
const fps: FilePath[] = []
async *emit(ctx, content, _resources) {
for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!)
for (const slug of file.data.aliases ?? []) {
const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await write({
yield write({
ctx,
content: `
<!DOCTYPE html>
@ -43,10 +41,7 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
slug,
ext: ".html",
})
fps.push(fp)
}
}
return fps
},
})

View File

@ -33,10 +33,9 @@ export const Assets: QuartzEmitterPlugin = () => {
return graph
},
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
async *emit({ argv, cfg }, _content, _resources) {
const assetsPath = argv.output
const fps = await filesToCopy(argv, cfg)
const res: FilePath[] = []
for (const fp of fps) {
const ext = path.extname(fp)
const src = joinSegments(argv.directory, fp) as FilePath
@ -46,10 +45,8 @@ export const Assets: QuartzEmitterPlugin = () => {
const dir = path.dirname(dest) as FilePath
await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
await fs.promises.copyFile(src, dest)
res.push(dest)
yield dest
}
return res
},
}
}

View File

@ -14,7 +14,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
async getDependencyGraph(_ctx, _content, _resources) {
return new DepGraph<FilePath>()
},
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
async emit({ argv, cfg }, _content, _resources) {
if (!cfg.configuration.baseUrl) {
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
return []
@ -24,7 +24,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
if (!content) {
return []
}
fs.writeFileSync(path, content)
await fs.promises.writeFile(path, content)
return [path] as FilePath[]
},
})

View File

@ -9,7 +9,7 @@ import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx"
import { QuartzComponent } from "../../components/types"
import { googleFontHref, joinStyles } from "../../util/theme"
import { googleFontHref, joinStyles, processGoogleFonts } from "../../util/theme"
import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
@ -207,8 +207,7 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
async getDependencyGraph(_ctx, _content, _resources) {
return new DepGraph<FilePath>()
},
async emit(ctx, _content, _resources): Promise<FilePath[]> {
const promises: Promise<FilePath>[] = []
async *emit(ctx, _content, _resources) {
const cfg = ctx.cfg.configuration
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
@ -217,42 +216,35 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
// let the user do it themselves in css
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
// when cdnCaching is true, we link to google fonts in Head.tsx
let match
const response = await fetch(googleFontHref(ctx.cfg.configuration.theme))
googleFontsStyleSheet = await response.text()
const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g
googleFontsStyleSheet = await (
await fetch(googleFontHref(ctx.cfg.configuration.theme))
).text()
while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) {
// match[0] is the `url(path)`, match[1] is the `path`
const url = match[1]
// the static name of this file.
const [filename, ext] = url.split("/").pop()!.split(".")
googleFontsStyleSheet = googleFontsStyleSheet.replace(
url,
`https://${cfg.baseUrl}/static/fonts/${filename}.ttf`,
if (!cfg.baseUrl) {
throw new Error(
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",
)
}
promises.push(
fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch font`)
}
return res.arrayBuffer()
})
.then((buf) =>
write({
ctx,
slug: joinSegments("static", "fonts", filename) as FullSlug,
ext: `.${ext}`,
content: Buffer.from(buf),
}),
),
)
const { processedStylesheet, fontFiles } = await processGoogleFonts(
googleFontsStyleSheet,
cfg.baseUrl,
)
googleFontsStyleSheet = processedStylesheet
// Download and save font files
for (const fontFile of fontFiles) {
const res = await fetch(fontFile.url)
if (!res.ok) {
throw new Error(`failed to fetch font ${fontFile.filename}`)
}
const buf = await res.arrayBuffer()
yield write({
ctx,
slug: joinSegments("static", "fonts", fontFile.filename) as FullSlug,
ext: `.${fontFile.extension}`,
content: Buffer.from(buf),
})
}
}
@ -267,45 +259,42 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
...componentResources.css,
styles,
)
const [prescript, postscript] = await Promise.all([
joinScripts(componentResources.beforeDOMLoaded),
joinScripts(componentResources.afterDOMLoaded),
])
promises.push(
write({
ctx,
slug: "index" as FullSlug,
ext: ".css",
content: transform({
filename: "index.css",
code: Buffer.from(stylesheet),
minify: true,
targets: {
safari: (15 << 16) | (6 << 8), // 15.6
ios_saf: (15 << 16) | (6 << 8), // 15.6
edge: 115 << 16,
firefox: 102 << 16,
chrome: 109 << 16,
},
include: Features.MediaQueries,
}).code.toString(),
}),
write({
yield write({
ctx,
slug: "index" as FullSlug,
ext: ".css",
content: transform({
filename: "index.css",
code: Buffer.from(stylesheet),
minify: true,
targets: {
safari: (15 << 16) | (6 << 8), // 15.6
ios_saf: (15 << 16) | (6 << 8), // 15.6
edge: 115 << 16,
firefox: 102 << 16,
chrome: 109 << 16,
},
include: Features.MediaQueries,
}).code.toString(),
}),
yield write({
ctx,
slug: "prescript" as FullSlug,
ext: ".js",
content: prescript,
}),
write({
yield write({
ctx,
slug: "postscript" as FullSlug,
ext: ".js",
content: postscript,
}),
)
return await Promise.all(promises)
})
},
}
}

View File

@ -117,9 +117,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
return graph
},
async emit(ctx, content, _resources) {
async *emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration
const emitted: FilePath[] = []
const linkIndex: ContentIndexMap = new Map()
for (const [tree, file] of content) {
const slug = file.data.slug!
@ -142,25 +141,21 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
}
if (opts?.enableSiteMap) {
emitted.push(
await write({
ctx,
content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as FullSlug,
ext: ".xml",
}),
)
yield write({
ctx,
content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as FullSlug,
ext: ".xml",
})
}
if (opts?.enableRSS) {
emitted.push(
await write({
ctx,
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
slug: (opts?.rssSlug ?? "index") as FullSlug,
ext: ".xml",
}),
)
yield write({
ctx,
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
slug: (opts?.rssSlug ?? "index") as FullSlug,
ext: ".xml",
})
}
const fp = joinSegments("static", "contentIndex") as FullSlug
@ -175,16 +170,12 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
}),
)
emitted.push(
await write({
ctx,
content: JSON.stringify(simplifiedIndex),
slug: fp,
ext: ".json",
}),
)
return emitted
yield write({
ctx,
content: JSON.stringify(simplifiedIndex),
slug: fp,
ext: ".json",
})
},
externalResources: (ctx) => {
if (opts?.enableRSS) {

View File

@ -94,9 +94,8 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
return graph
},
async emit(ctx, content, resources): Promise<FilePath[]> {
async *emit(ctx, content, resources) {
const cfg = ctx.cfg.configuration
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
let containsIndex = false
@ -118,14 +117,12 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
yield write({
ctx,
content,
slug,
ext: ".html",
})
fps.push(fp)
}
if (!containsIndex && !ctx.argv.fastRebuild) {
@ -135,8 +132,6 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
),
)
}
return fps
},
}
}

View File

@ -69,8 +69,7 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
return graph
},
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@ -119,16 +118,13 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
yield write({
ctx,
content,
slug,
ext: ".html",
})
fps.push(fp)
}
return fps
},
}
}

View File

@ -2,12 +2,13 @@ import path from "path"
import fs from "fs"
import { BuildCtx } from "../../util/ctx"
import { FilePath, FullSlug, joinSegments } from "../../util/path"
import { Readable } from "stream"
type WriteOptions = {
ctx: BuildCtx
slug: FullSlug
ext: `.${string}` | ""
content: string | Buffer
content: string | Buffer | Readable
}
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {

View File

@ -8,3 +8,4 @@ export { Static } from "./static"
export { ComponentResources } from "./componentResources"
export { NotFoundPage } from "./404"
export { CNAME } from "./cname"
export { CustomOgImages } from "./ogImage"

View File

@ -0,0 +1,134 @@
import { QuartzEmitterPlugin } from "../types"
import { i18n } from "../../i18n"
import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import sharp from "sharp"
import satori from "satori"
import { loadEmoji, getIconCode } from "../../util/emoji"
import { Readable } from "stream"
import { write } from "./helpers"
const defaultOptions: SocialImageOptions = {
colorScheme: "lightMode",
width: 1200,
height: 630,
imageStructure: defaultImage,
excludeRoot: false,
}
/**
* Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder
* @param opts options for generating image
*/
async function generateSocialImage(
{ cfg, description, fonts, title, fileData }: ImageOptions,
userOpts: SocialImageOptions,
): Promise<Readable> {
const { width, height } = userOpts
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
const svg = await satori(imageComponent, {
width,
height,
fonts,
loadAdditionalAsset: async (languageCode: string, segment: string) => {
if (languageCode === "emoji") {
return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}`
}
return languageCode
},
})
return sharp(Buffer.from(svg)).webp({ quality: 40 })
}
export const CustomOgImagesEmitterName = "CustomOgImages"
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
const fullOptions = { ...defaultOptions, ...userOpts }
return {
name: CustomOgImagesEmitterName,
getQuartzComponents() {
return []
},
async *emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration
const headerFont = cfg.theme.typography.header
const bodyFont = cfg.theme.typography.body
const fonts = await getSatoriFonts(headerFont, bodyFont)
for (const [_tree, vfile] of content) {
// if this file defines socialImage, we can skip
if (vfile.data.frontmatter?.socialImage !== undefined) {
continue
}
const slug = vfile.data.slug!
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
const description =
vfile.data.frontmatter?.socialDescription ??
vfile.data.frontmatter?.description ??
unescapeHTML(
vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description,
)
const stream = await generateSocialImage(
{
title,
description,
fonts,
cfg,
fileData: vfile.data,
},
fullOptions,
)
yield write({
ctx,
content: stream,
slug: `${slug}-og-image` as FullSlug,
ext: ".webp",
})
}
},
externalResources: (ctx) => {
if (!ctx.cfg.configuration.baseUrl) {
return {}
}
const baseUrl = ctx.cfg.configuration.baseUrl
return {
additionalHead: [
(pageData) => {
const isRealFile = pageData.filePath !== undefined
const userDefinedOgImagePath = pageData.frontmatter?.socialImage
const generatedOgImagePath = isRealFile
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
: undefined
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
return (
<>
{!userDefinedOgImagePath && (
<>
<meta property="og:image:width" content={fullOptions.width.toString()} />
<meta property="og:image:height" content={fullOptions.height.toString()} />
</>
)}
<meta property="og:image" content={ogImagePath} />
<meta property="og:image:url" content={ogImagePath} />
<meta name="twitter:image" content={ogImagePath} />
<meta property="og:image:type" content={ogImageMimeType} />
</>
)
},
],
}
},
}
}

View File

@ -3,6 +3,7 @@ import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import { glob } from "../../util/glob"
import DepGraph from "../../depgraph"
import { dirname } from "path"
export const Static: QuartzEmitterPlugin = () => ({
name: "Static",
@ -20,13 +21,17 @@ export const Static: QuartzEmitterPlugin = () => ({
return graph
},
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
async *emit({ argv, cfg }, _content) {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
recursive: true,
dereference: true,
})
return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[]
const outputStaticPath = joinSegments(argv.output, "static")
await fs.promises.mkdir(outputStaticPath, { recursive: true })
for (const fp of fps) {
const src = joinSegments(staticPath, fp) as FilePath
const dest = joinSegments(outputStaticPath, fp) as FilePath
await fs.promises.mkdir(dirname(dest), { recursive: true })
await fs.promises.copyFile(src, dest)
yield dest
}
},
})

View File

@ -71,8 +71,7 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
return graph
},
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@ -127,16 +126,13 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
yield write({
ctx,
content,
slug: file.data.slug!,
ext: ".html",
})
fps.push(fp)
}
return fps
},
}
}