diff --git a/docs/advanced/making plugins.md b/docs/advanced/making plugins.md
index 8ed533f88..b65bd374e 100644
--- a/docs/advanced/making plugins.md
+++ b/docs/advanced/making plugins.md
@@ -25,10 +25,11 @@ The following sections will go into detail for what methods can be implemented f
- `BuildCtx` is defined in `quartz/ctx.ts`. It consists of
- `argv`: The command line arguments passed to the Quartz [[build]] command
- `cfg`: The full Quartz [[configuration]]
- - `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a `ServerSlug` is)
+ - `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a slug is)
- `StaticResources` is defined in `quartz/resources.tsx`. It consists of
- `css`: a list of CSS style definitions that should be loaded. A CSS style is described with the `CSSResource` type which is also defined in `quartz/resources.tsx`. It accepts either a source URL or the inline content of the stylesheet.
- `js`: a list of scripts that should be loaded. A script is described with the `JSResource` type which is also defined in `quartz/resources.tsx`. It allows you to define a load time (either before or after the DOM has been loaded), whether it should be a module, and either the source URL or the inline content of the script.
+ - `additionalHead`: a list of JSX elements or functions that return JSX elements to be added to the `
` tag of the page. Functions receive the page's data as an argument and can conditionally render elements.
## Transformers
@@ -234,7 +235,7 @@ export type WriteOptions = (data: {
// the build context
ctx: BuildCtx
// the name of the file to emit (not including the file extension)
- slug: ServerSlug
+ slug: FullSlug
// the file extension
ext: `.${string}` | ""
// the file content to add
diff --git a/quartz.config.ts b/quartz.config.ts
index 51a75515d..4b51e16cc 100644
--- a/quartz.config.ts
+++ b/quartz.config.ts
@@ -19,7 +19,6 @@ const config: QuartzConfig = {
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
- generateSocialImages: true,
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,
diff --git a/quartz/cfg.ts b/quartz/cfg.ts
index 135f58499..1c98b9363 100644
--- a/quartz/cfg.ts
+++ b/quartz/cfg.ts
@@ -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
theme: Theme
/**
* Allow to translate the date in the language of your choice.
diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx
index b6a7e8d07..60dce6edd 100644
--- a/quartz/components/Head.tsx
+++ b/quartz/components/Head.tsx
@@ -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
-
- 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 (
{title}
@@ -181,7 +49,7 @@ export default (() => {
)}
- {/* OG/Twitter meta tags */}
+
@@ -189,28 +57,32 @@ export default (() => {
-
- {/* Dont set width and height if unknown (when using custom frontmatter image) */}
- {!frontmatterImgUrl && (
+
+ {!usesCustomOgImage && (
<>
-
-
+
+
+
+
>
)}
-
+
{cfg.baseUrl && (
<>
-
-
>
)}
+
+
{css.map((resource) => CSSResourceToStyleElement(resource, true))}
{js
.filter((resource) => resource.loadTime === "beforeDOMReady")
diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts
index 523151c2c..6218178a4 100644
--- a/quartz/plugins/emitters/helpers.ts
+++ b/quartz/plugins/emitters/helpers.ts
@@ -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 => {
diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts
index 60f47fe01..842ffb083 100644
--- a/quartz/plugins/emitters/index.ts
+++ b/quartz/plugins/emitters/index.ts
@@ -8,3 +8,4 @@ export { Static } from "./static"
export { ComponentResources } from "./componentResources"
export { NotFoundPage } from "./404"
export { CNAME } from "./cname"
+export { CustomOgImages } from "./ogImage"
diff --git a/quartz/plugins/emitters/ogImage.tsx b/quartz/plugins/emitters/ogImage.tsx
new file mode 100644
index 000000000..856c8102d
--- /dev/null
+++ b/quartz/plugins/emitters/ogImage.tsx
@@ -0,0 +1,142 @@
+import { QuartzEmitterPlugin } from "../types"
+import { i18n } from "../../i18n"
+import { unescapeHTML } from "../../util/escape"
+import { FilePath, FullSlug, getFileExtension } from "../../util/path"
+import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFont } from "../../util/og"
+import { getFontSpecificationName } from "../../util/theme"
+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 {
+ 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> = (userOpts) => {
+ const fullOptions = { ...defaultOptions, ...userOpts }
+
+ return {
+ name: CustomOgImagesEmitterName,
+ getQuartzComponents() {
+ return []
+ },
+ async emit(ctx, content, _resources) {
+ const cfg = ctx.cfg.configuration
+ const emittedFiles: FilePath[] = []
+
+ const headerFont = getFontSpecificationName(cfg.theme.typography.header)
+ const bodyFont = getFontSpecificationName(cfg.theme.typography.body)
+ const fonts = await getSatoriFont(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,
+ )
+
+ const fp = await write({
+ ctx,
+ content: stream,
+ slug: `${slug}-og-image` as FullSlug,
+ ext: ".webp",
+ })
+
+ emittedFiles.push(fp)
+ }
+
+ return emittedFiles
+ },
+ 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 && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+ >
+ )
+ },
+ ],
+ }
+ },
+ }
+}
diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts
index 9679bd1ec..b3a916a65 100644
--- a/quartz/plugins/transformers/frontmatter.ts
+++ b/quartz/plugins/transformers/frontmatter.ts
@@ -131,6 +131,7 @@ declare module "vfile" {
created: string
published: string
description: string
+ socialDescription: string
publish: boolean | string
draft: boolean | string
lang: string
diff --git a/quartz/util/og.tsx b/quartz/util/og.tsx
index 4d675cbd4..bd1a5b4a1 100644
--- a/quartz/util/og.tsx
+++ b/quartz/util/og.tsx
@@ -108,22 +108,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
+ fonts: SatoriOptions["fonts"]
/**
* `GlobalConfiguration` of quartz (used for theme/typography)
*/
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index 8f8502979..6d99c364e 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -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]
}