mirror of
https://github.com/ZetaKebab/quartz.git
synced 2025-06-22 00:34:37 +00:00
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:
@ -250,15 +250,25 @@ async function partialRebuildFromEntrypoint(
|
||||
([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
|
||||
)
|
||||
|
||||
const emittedFps = await emitter.emit(ctx, files, staticResources)
|
||||
|
||||
if (ctx.argv.verbose) {
|
||||
for (const file of emittedFps) {
|
||||
console.log(`[emit:${emitter.name}] ${file}`)
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
@ -280,15 +290,24 @@ async function partialRebuildFromEntrypoint(
|
||||
.filter((file) => !toRemove.has(file))
|
||||
.map((file) => contentMap.get(file)!)
|
||||
|
||||
const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources)
|
||||
|
||||
if (ctx.argv.verbose) {
|
||||
for (const file of emittedFps) {
|
||||
console.log(`[emit:${emitter.name}] ${file}`)
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,10 +61,6 @@ export interface GlobalConfiguration {
|
||||
* Quartz will avoid using this as much as possible and use relative URLs most of the time
|
||||
*/
|
||||
baseUrl?: string
|
||||
/**
|
||||
* Whether to generate social images (Open Graph and Twitter standard) for link previews
|
||||
*/
|
||||
generateSocialImages: boolean | Partial<SocialImageOptions>
|
||||
theme: Theme
|
||||
/**
|
||||
* Allow to translate the date in the language of your choice.
|
||||
|
@ -102,7 +102,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
|
||||
// Add current slug to full path
|
||||
currentPath = joinSegments(currentPath, slugParts[i])
|
||||
const includeTrailingSlash = !isTagPath || i < 1
|
||||
const includeTrailingSlash = !isTagPath || i < slugParts.length - 1
|
||||
|
||||
// Format and add current crumb
|
||||
const crumb = formatCrumb(
|
||||
|
@ -1,173 +1,41 @@
|
||||
import { i18n } from "../i18n"
|
||||
import { FullSlug, joinSegments, pathToRoot } from "../util/path"
|
||||
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
|
||||
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
||||
import { getFontSpecificationName, googleFontHref } from "../util/theme"
|
||||
import { googleFontHref } from "../util/theme"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import satori, { SatoriOptions } from "satori"
|
||||
import { loadEmoji, getIconCode } from "../util/emoji"
|
||||
import fs from "fs"
|
||||
import sharp from "sharp"
|
||||
import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og"
|
||||
import { unescapeHTML } from "../util/escape"
|
||||
|
||||
/**
|
||||
* 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, fileName, fontsPromise, title, fileData }: ImageOptions,
|
||||
userOpts: SocialImageOptions,
|
||||
imageDir: string,
|
||||
) {
|
||||
const fonts = await fontsPromise
|
||||
const { width, height } = userOpts
|
||||
|
||||
// JSX that will be used to generate satori svg
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
// Convert svg directly to webp (with additional compression)
|
||||
const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer()
|
||||
|
||||
// Write to file system
|
||||
const filePath = joinSegments(imageDir, `${fileName}.${extension}`)
|
||||
fs.writeFileSync(filePath, compressed)
|
||||
}
|
||||
|
||||
const extension = "webp"
|
||||
|
||||
const defaultOptions: SocialImageOptions = {
|
||||
colorScheme: "lightMode",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
imageStructure: defaultImage,
|
||||
excludeRoot: false,
|
||||
}
|
||||
|
||||
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
|
||||
export default (() => {
|
||||
let fontsPromise: Promise<SatoriOptions["fonts"]>
|
||||
|
||||
let fullOptions: SocialImageOptions
|
||||
const Head: QuartzComponent = ({
|
||||
cfg,
|
||||
fileData,
|
||||
externalResources,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
// Initialize options if not set
|
||||
if (!fullOptions) {
|
||||
if (typeof cfg.generateSocialImages !== "boolean") {
|
||||
fullOptions = { ...defaultOptions, ...cfg.generateSocialImages }
|
||||
} else {
|
||||
fullOptions = defaultOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Memoize google fonts
|
||||
if (!fontsPromise && cfg.generateSocialImages) {
|
||||
const headerFont = getFontSpecificationName(cfg.theme.typography.header)
|
||||
const bodyFont = getFontSpecificationName(cfg.theme.typography.body)
|
||||
fontsPromise = getSatoriFont(headerFont, bodyFont)
|
||||
}
|
||||
|
||||
const slug = fileData.filePath
|
||||
// since "/" is not a valid character in file names, replace with "-"
|
||||
const fileName = slug?.replaceAll("/", "-")
|
||||
|
||||
// Get file description (priority: frontmatter > fileData > default)
|
||||
const fdDescription =
|
||||
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
|
||||
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
||||
const title =
|
||||
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
||||
let description = ""
|
||||
if (fdDescription) {
|
||||
description = unescapeHTML(fdDescription)
|
||||
}
|
||||
|
||||
if (fileData.frontmatter?.socialDescription) {
|
||||
description = fileData.frontmatter?.socialDescription as string
|
||||
} else if (fileData.frontmatter?.description) {
|
||||
description = fileData.frontmatter?.description
|
||||
}
|
||||
|
||||
const fileDir = joinSegments(ctx.argv.output, "static", "social-images")
|
||||
if (cfg.generateSocialImages) {
|
||||
// Generate folders for social images (if they dont exist yet)
|
||||
if (!fs.existsSync(fileDir)) {
|
||||
fs.mkdirSync(fileDir, { recursive: true })
|
||||
}
|
||||
|
||||
if (fileName) {
|
||||
// Generate social image (happens async)
|
||||
void generateSocialImage(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
fileName,
|
||||
fileDir,
|
||||
fileExt: extension,
|
||||
fontsPromise,
|
||||
cfg,
|
||||
fileData,
|
||||
},
|
||||
fullOptions,
|
||||
fileDir,
|
||||
)
|
||||
}
|
||||
}
|
||||
const description =
|
||||
fileData.frontmatter?.socialDescription ??
|
||||
fileData.frontmatter?.description ??
|
||||
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
|
||||
|
||||
const { css, js, additionalHead } = externalResources
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
|
||||
|
||||
const iconPath = joinSegments(baseDir, "static/icon.png")
|
||||
|
||||
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
|
||||
// "static/social-images/slug-filename.md.webp"
|
||||
const ogImageGeneratedPath = `https://${cfg.baseUrl}/${fileDir.replace(
|
||||
`${ctx.argv.output}/`,
|
||||
"",
|
||||
)}/${fileName}.${extension}`
|
||||
|
||||
// Use default og image if filePath doesnt exist (for autogenerated paths with no .md file)
|
||||
const useDefaultOgImage = fileName === undefined || !cfg.generateSocialImages
|
||||
|
||||
// Path to og/social image (priority: frontmatter > generated image (if enabled) > default image)
|
||||
let ogImagePath = useDefaultOgImage ? ogImageDefaultPath : ogImageGeneratedPath
|
||||
|
||||
// TODO: could be improved to support external images in the future
|
||||
// Aliases for image and cover handled in `frontmatter.ts`
|
||||
const frontmatterImgUrl = fileData.frontmatter?.socialImage
|
||||
|
||||
// Override with default og image if config option is set
|
||||
if (fileData.slug === "index") {
|
||||
ogImagePath = ogImageDefaultPath
|
||||
}
|
||||
|
||||
// Override with frontmatter url if existing
|
||||
if (frontmatterImgUrl) {
|
||||
ogImagePath = `https://${cfg.baseUrl}/static/${frontmatterImgUrl}`
|
||||
}
|
||||
|
||||
// Url of current page
|
||||
const socialUrl =
|
||||
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
|
||||
|
||||
const usesCustomOgImage = ctx.cfg.plugins.emitters.some(
|
||||
(e) => e.name === CustomOgImagesEmitterName,
|
||||
)
|
||||
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
|
||||
|
||||
return (
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
@ -181,7 +49,7 @@ export default (() => {
|
||||
)}
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
{/* OG/Twitter meta tags */}
|
||||
|
||||
<meta name="og:site_name" content={cfg.pageTitle}></meta>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:type" content="website" />
|
||||
@ -189,28 +57,32 @@ export default (() => {
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image:type" content={`image/${extension}`} />
|
||||
<meta property="og:image:alt" content={description} />
|
||||
{/* Dont set width and height if unknown (when using custom frontmatter image) */}
|
||||
{!frontmatterImgUrl && (
|
||||
|
||||
{!usesCustomOgImage && (
|
||||
<>
|
||||
<meta property="og:image:width" content={fullOptions.width.toString()} />
|
||||
<meta property="og:image:height" content={fullOptions.height.toString()} />
|
||||
<meta property="og:image" content={ogImageDefaultPath} />
|
||||
<meta property="og:image:url" content={ogImageDefaultPath} />
|
||||
<meta name="twitter:image" content={ogImageDefaultPath} />
|
||||
<meta
|
||||
property="og:image:type"
|
||||
content={`image/${getFileExtension(ogImageDefaultPath) ?? "png"}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<meta property="og:image:url" content={ogImagePath} />
|
||||
|
||||
{cfg.baseUrl && (
|
||||
<>
|
||||
<meta name="twitter:image" content={ogImagePath} />
|
||||
<meta property="og:image" content={ogImagePath} />
|
||||
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
|
||||
<meta property="og:url" content={socialUrl}></meta>
|
||||
<meta property="twitter:url" content={socialUrl}></meta>
|
||||
</>
|
||||
)}
|
||||
|
||||
<link rel="icon" href={iconPath} />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content="Quartz" />
|
||||
|
||||
{css.map((resource) => CSSResourceToStyleElement(resource, true))}
|
||||
{js
|
||||
.filter((resource) => resource.loadTime === "beforeDOMReady")
|
||||
|
@ -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",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
})
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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[]
|
||||
},
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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> => {
|
||||
|
@ -8,3 +8,4 @@ export { Static } from "./static"
|
||||
export { ComponentResources } from "./componentResources"
|
||||
export { NotFoundPage } from "./404"
|
||||
export { CNAME } from "./cname"
|
||||
export { CustomOgImages } from "./ogImage"
|
||||
|
134
quartz/plugins/emitters/ogImage.tsx
Normal file
134
quartz/plugins/emitters/ogImage.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -131,6 +131,7 @@ declare module "vfile" {
|
||||
created: string
|
||||
published: string
|
||||
description: string
|
||||
socialDescription: string
|
||||
publish: boolean | string
|
||||
draft: boolean | string
|
||||
lang: string
|
||||
|
@ -38,7 +38,11 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
||||
) => QuartzEmitterPluginInstance
|
||||
export type QuartzEmitterPluginInstance = {
|
||||
name: string
|
||||
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||
emit(
|
||||
ctx: BuildCtx,
|
||||
content: ProcessedContent[],
|
||||
resources: StaticResources,
|
||||
): Promise<FilePath[]> | AsyncGenerator<FilePath>
|
||||
/**
|
||||
* Returns the components (if any) that are used in rendering the page.
|
||||
* This helps Quartz optimize the page by only including necessary resources
|
||||
|
@ -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
|
||||
@ -14,20 +15,36 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
|
||||
|
||||
let emittedFiles = 0
|
||||
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||
for (const emitter of cfg.plugins.emitters) {
|
||||
try {
|
||||
const emitted = await emitter.emit(ctx, content, staticResources)
|
||||
emittedFiles += emitted.length
|
||||
|
||||
if (ctx.argv.verbose) {
|
||||
for (const file of emitted) {
|
||||
console.log(`[emit:${emitter.name}] ${file}`)
|
||||
await Promise.all(
|
||||
cfg.plugins.emitters.map(async (emitter) => {
|
||||
try {
|
||||
const emitted = await emitter.emit(ctx, content, staticResources)
|
||||
if (Symbol.asyncIterator in emitted) {
|
||||
// Async generator case
|
||||
for await (const file of emitted) {
|
||||
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
|
||||
for (const file of emitted) {
|
||||
if (ctx.argv.verbose) {
|
||||
console.log(`[emit:${emitter.name}] ${file}`)
|
||||
} else {
|
||||
log.updateText(`Emitting output files: ${chalk.gray(file)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error)
|
||||
}
|
||||
} catch (err) {
|
||||
trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
log.end(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince()}`)
|
||||
}
|
||||
|
@ -1,26 +1,43 @@
|
||||
import { Spinner } from "cli-spinner"
|
||||
import readline from "readline"
|
||||
|
||||
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(() => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
updateText(text: string) {
|
||||
this.spinnerText = text
|
||||
}
|
||||
|
||||
end(text?: string) {
|
||||
if (!this.verbose) {
|
||||
this.spinner!.stop(true)
|
||||
if (!this.verbose && this.spinnerInterval) {
|
||||
clearInterval(this.spinnerInterval)
|
||||
this.spinnerInterval = undefined
|
||||
readline.clearLine(process.stdout, 0)
|
||||
readline.cursorTo(process.stdout, 0)
|
||||
}
|
||||
|
||||
if (text) {
|
||||
console.log(text)
|
||||
}
|
||||
|
@ -1,28 +1,55 @@
|
||||
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
|
||||
}
|
||||
|
||||
@ -32,32 +59,49 @@ export async function getSatoriFont(headerFontName: string, bodyFontName: string
|
||||
* @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/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")
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
@ -108,22 +152,10 @@ export type ImageOptions = {
|
||||
* what description to use as body in image
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@ -141,68 +173,94 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||
title: string,
|
||||
description: string,
|
||||
fonts: SatoriOptions["fonts"],
|
||||
_fileData: QuartzPluginData,
|
||||
fileData: QuartzPluginData,
|
||||
) => {
|
||||
const fontBreakPoint = 22
|
||||
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",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
backgroundColor: cfg.theme.colors[colorScheme].light,
|
||||
gap: "2rem",
|
||||
padding: "1.5rem 5rem",
|
||||
padding: "2.5rem",
|
||||
fontFamily: fonts[1].name,
|
||||
}}
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
gap: "2.5rem",
|
||||
gap: "1rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<img src={iconPath} width={135} height={135} />
|
||||
<img
|
||||
src={iconPath}
|
||||
width={56}
|
||||
height={56}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
color: cfg.theme.colors[colorScheme].dark,
|
||||
fontSize: useSmallerFont ? 70 : 82,
|
||||
fontFamily: fonts[0].name,
|
||||
maxWidth: "70%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontSize: 32,
|
||||
color: cfg.theme.colors[colorScheme].gray,
|
||||
fontFamily: fonts[1].name,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{cfg.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title Section */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
color: cfg.theme.colors[colorScheme].dark,
|
||||
fontSize: 44,
|
||||
fontFamily: fonts[1].name,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "40%",
|
||||
overflow: "hidden",
|
||||
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
|
||||
@ -210,14 +268,80 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||
margin: 0,
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitLineClamp: 4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ export type RelativeURL = SlugLike<"relative">
|
||||
export function isRelativeURL(s: string): s is RelativeURL {
|
||||
const validStart = /^\.{1,2}/.test(s)
|
||||
const validEnding = !endsWith(s, "index")
|
||||
return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "")
|
||||
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
|
||||
}
|
||||
|
||||
export function getFullSlug(window: Window): FullSlug {
|
||||
@ -61,7 +61,7 @@ function sluggify(s: string): string {
|
||||
|
||||
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
||||
fp = stripSlashes(fp) as FilePath
|
||||
let ext = _getFileExtension(fp)
|
||||
let ext = getFileExtension(fp)
|
||||
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
|
||||
if (excludeExt || [".md", ".html", undefined].includes(ext)) {
|
||||
ext = ""
|
||||
@ -272,10 +272,10 @@ function containsForbiddenCharacters(s: string): boolean {
|
||||
}
|
||||
|
||||
function _hasFileExtension(s: string): boolean {
|
||||
return _getFileExtension(s) !== undefined
|
||||
return getFileExtension(s) !== undefined
|
||||
}
|
||||
|
||||
function _getFileExtension(s: string): string | undefined {
|
||||
export function getFileExtension(s: string): string | undefined {
|
||||
return s.match(/\.[A-Za-z0-9]+$/)?.[0]
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,7 @@ interface Colors {
|
||||
darkMode: ColorScheme
|
||||
}
|
||||
|
||||
type FontSpecification =
|
||||
export type FontSpecification =
|
||||
| string
|
||||
| {
|
||||
name: string
|
||||
@ -90,6 +90,36 @@ export function googleFontHref(theme: Theme) {
|
||||
return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap`
|
||||
}
|
||||
|
||||
export interface GoogleFontFile {
|
||||
url: string
|
||||
filename: string
|
||||
extension: string
|
||||
}
|
||||
|
||||
export async function processGoogleFonts(
|
||||
stylesheet: string,
|
||||
baseUrl: string,
|
||||
): Promise<{
|
||||
processedStylesheet: string
|
||||
fontFiles: GoogleFontFile[]
|
||||
}> {
|
||||
const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g
|
||||
const fontFiles: GoogleFontFile[] = []
|
||||
let processedStylesheet = stylesheet
|
||||
|
||||
let match
|
||||
while ((match = fontSourceRegex.exec(stylesheet)) !== null) {
|
||||
const url = match[1]
|
||||
const [filename, extension] = url.split("/").pop()!.split(".")
|
||||
const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`
|
||||
|
||||
processedStylesheet = processedStylesheet.replace(url, staticUrl)
|
||||
fontFiles.push({ url, filename, extension })
|
||||
}
|
||||
|
||||
return { processedStylesheet, fontFiles }
|
||||
}
|
||||
|
||||
export function joinStyles(theme: Theme, ...stylesheet: string[]) {
|
||||
return `
|
||||
${stylesheet.join("\n\n")}
|
||||
|
Reference in New Issue
Block a user