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] }