diff --git a/docs/features/social images.md b/docs/features/social images.md index ebcafed82..bce7f9bc7 100644 --- a/docs/features/social images.md +++ b/docs/features/social images.md @@ -2,8 +2,7 @@ title: "Social Media Preview Cards" --- -A lot of social media platforms can display a rich preview for your website when sharing a link (most notably, a cover image, a title and a description). Quartz automatically handles most of this for you with reasonable defaults, but for more control, you can customize these by setting [[#Properties | Frontmatter Properties]]. - +A lot of social media platforms can display a rich preview for your website when sharing a link (most notably, a cover image, a title and a description). Quartz automatically handles most of this for you with reasonable defaults, but for more control, you can customize these by setting [[social images#Frontmatter Properties]]. Quartz can also dynamically generate and use new cover images for every page to be used in link previews on social media for you. To get started with this, set `generateSocialImages: true` in `quartz.config.ts`. ## Showcase @@ -14,7 +13,7 @@ After enabling `generateSocialImages` in `quartz.config.ts`, the social media li | ----------------------------------- | ---------------------------------- | | ![[social-image-preview-light.png]] | ![[social-image-preview-dark.png]] | -For testing, it is recommended to use [opengraph.xyz](https://www.opengraph.xyz/) to see what the link to your page will look like on various platforms (more info under [[#local testing]]). +For testing, it is recommended to use [opengraph.xyz](https://www.opengraph.xyz/) to see what the link to your page will look like on various platforms (more info under [[social images#local testing]]). ## Customization @@ -28,15 +27,9 @@ generateSocialImages: { width: 1200, // width to generate with (in pixels) height: 630, // height to generate with (in pixels) excludeRoot: false, // wether to exclude "/" index path to be excluded from auto generated images (false = use auto, true = use default og image) - imageStructure: defaultImage // import from `socialImage.tsx`, recommended to add your own one there as well } ``` -> [!info] Info -> -> To change the default config, you can pass an object containing all config options you want to customize to `generateSocialImages`. -> As a simple example, if you want to change the theme, you can pass `generateSocialImages: { colorScheme: "darkMode" }` - --- ### Frontmatter Properties @@ -56,7 +49,7 @@ The `socialImage` property should contain a link to an image relative to `quartz > [!info] Info > -> The priority for what image will be used for the cover image looks like the following: `frontmatter property> generated image (if enabled) > default image`. +> The priority for what image will be used for the cover image looks like the following: `frontmatter property > generated image (if enabled) > default image`. > > The default image (`quartz/static/og-image.png`) will only be used as a fallback if nothing else is set. If `generateSocialImages` is enabled, it will be treated as the new default per page, but can be overwritten by setting the `socialImage` frontmatter property for that page. @@ -66,7 +59,7 @@ The `socialImage` property should contain a link to an image relative to `quartz You can fully customize how the images being generated look by passing your own component to `generateSocialImages.imageStructure`. This component takes html/css + some page metadata/config options and converts it to an image using [satori](https://github.com/vercel/satori). Vercel provides an [online playground](https://og-playground.vercel.app/) that can be used to preview how your html/css looks like as a picture. This is ideal for prototyping your custom design. -It is recommended to write your own image components in `quartz/util/socialImage.tsx` or any other `.tsx` file, as passing them to the config won't work otherwise. An example of the default image component can be found in `socialImage.tsx` in `defaultImage()`. +It is recommended to write your own image components in `quartz/util/og.tsx` or any other `.tsx` file, as passing them to the config won't work otherwise. An example of the default image component can be found in `og.tsx` in `defaultImage()`. > [!tip] Hint > @@ -76,7 +69,7 @@ Your custom image component should have the `SocialImageOptions["imageStructure" ```ts imageStructure: ( - cfg: GlobalConfiguration, // global quartz config (useful for getting theme colors and other info) + cfg: GlobalConfiguration, // global Quartz config (useful for getting theme colors and other info) userOpts: UserOpts, // options passed to `generateSocialImage` title: string, // title of current page description: string, // description of current page @@ -116,6 +109,47 @@ export const myImage: SocialImageOptions["imageStructure"] = (...) => { } ``` +> [!example]- Local fonts +> +> For cases where you use a local fonts under `static` folder, make sure to set the correct `@font-face` in `custom.scss` +> +> ```scss title="custom.scss" +> @font-face { +> font-family: "Newsreader"; +> font-style: normal; +> font-weight: normal; +> font-display: swap; +> src: url("/static/Newsreader.woff2") format("woff2"); +> } +> ``` +> +> Then in `quartz/util/og.tsx`, you can load the satori fonts like so: +> +> ```tsx title="quartz/util/og.tsx" +> const headerFont = joinSegments("static", "Newsreader.woff2") +> const bodyFont = joinSegments("static", "Newsreader.woff2") +> +> export async function getSatoriFont(cfg: GlobalConfiguration): Promise { +> const headerWeight: FontWeight = 700 +> const bodyWeight: FontWeight = 400 +> +> const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) +> +> const [header, body] = await Promise.all( +> [headerFont, bodyFont].map((font) => +> fetch(`${url.toString()}/${font}`).then((res) => res.arrayBuffer()), +> ), +> ) +> +> return [ +> { name: cfg.theme.typography.header, data: header, weight: headerWeight, style: "normal" }, +> { name: cfg.theme.typography.body, data: body, weight: bodyWeight, style: "normal" }, +> ] +> } +> ``` +> +> This font then can be used with your custom structure + ### Local testing To test how the full preview of your page is going to look even before deploying, you can forward the port you're serving quartz on. In VSCode, this can easily be achieved following [this guide](https://code.visualstudio.com/docs/editor/port-forwarding) (make sure to set `Visibility` to `public` if testing on external tools like [opengraph.xyz](https://www.opengraph.xyz/)). @@ -238,3 +272,130 @@ export const customImage: SocialImageOptions["imageStructure"] = ( ) } ``` + +> [!example]- Advanced example +> +> The following example includes a customized social image with a custom background and formatted date. +> +> ```typescript title="custom-og.tsx" +> export const og: SocialImageOptions["Component"] = ( +> cfg: GlobalConfiguration, +> fileData: QuartzPluginData, +> { colorScheme }: Options, +> title: string, +> description: string, +> fonts: SatoriOptions["fonts"], +> ) => { +> let created: string | undefined +> let reading: string | undefined +> if (fileData.dates) { +> created = formatDate(getDate(cfg, fileData)!, cfg.locale) +> } +> const { minutes, text: _timeTaken, words: _words } = readingTime(fileData.text!) +> reading = i18n(cfg.locale).components.contentMeta.readingTime({ +> minutes: Math.ceil(minutes), +> }) +> +> const Li = [created, reading] +> +> return ( +>
style={{ +> position: "relative", +> display: "flex", +> flexDirection: "row", +> alignItems: "flex-start", +> height: "100%", +> width: "100%", +> backgroundImage: `url("https://${cfg.baseUrl}/static/og-image.jpeg")`, +> backgroundSize: "100% 100%", +> }} +> > +>
style={{ +> position: "absolute", +> top: 0, +> left: 0, +> right: 0, +> bottom: 0, +> background: "radial-gradient(circle at center, transparent, rgba(0, 0, 0, 0.4) 70%)", +> }} +> /> +>
style={{ +> display: "flex", +> height: "100%", +> width: "100%", +> flexDirection: "column", +> justifyContent: "flex-start", +> alignItems: "flex-start", +> gap: "1.5rem", +> paddingTop: "4rem", +> paddingBottom: "4rem", +> marginLeft: "4rem", +> }} +> > +> src={`"https://${cfg.baseUrl}/static/icon.jpeg"`} +> style={{ +> position: "relative", +> backgroundClip: "border-box", +> borderRadius: "6rem", +> }} +> width={80} +> /> +>
style={{ +> display: "flex", +> flexDirection: "column", +> textAlign: "left", +> fontFamily: fonts[0].name, +> }} +> > +>

style={{ +> color: cfg.theme.colors[colorScheme].light, +> fontSize: "3rem", +> fontWeight: 700, +> marginRight: "4rem", +> fontFamily: fonts[0].name, +> }} +> > +> {title} +>

+>
    style={{ +> color: cfg.theme.colors[colorScheme].gray, +> gap: "1rem", +> fontSize: "1.5rem", +> fontFamily: fonts[1].name, +> }} +> > +> {Li.map((item, index) => { +> if (item) { +> return
  • {item}
  • +> } +> })} +>
+>
+>

style={{ +> color: cfg.theme.colors[colorScheme].light, +> fontSize: "1.5rem", +> overflow: "hidden", +> marginRight: "8rem", +> textOverflow: "ellipsis", +> display: "-webkit-box", +> WebkitLineClamp: 7, +> WebkitBoxOrient: "vertical", +> lineClamp: 7, +> fontFamily: fonts[1].name, +> }} +> > +> {description} +>

+>
+>
+> ) +> } +> ``` diff --git a/quartz/cfg.ts b/quartz/cfg.ts index b9cdedac4..135f58499 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -2,7 +2,7 @@ import { ValidDateType } from "./components/Date" import { QuartzComponent } from "./components/types" import { ValidLocale } from "./i18n" import { PluginTypes } from "./plugins/types" -import { SocialImageOptions } from "./util/imageHelper" +import { SocialImageOptions } from "./util/og" import { Theme } from "./util/theme" export type Analytics = @@ -62,14 +62,14 @@ export interface GlobalConfiguration { */ baseUrl?: string /** - * Wether to generate social images (Open Graph and Twitter standard) for link previews + * 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. * Also used for UI translation (default: en-US) - * Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag + * Need to be formatted following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag * The first part is the language (en) and the second part is the script/region (US) * Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes * Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 0146179f8..6c96ee53e 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -5,9 +5,8 @@ import { googleFontHref } from "../util/theme" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import satori, { SatoriOptions } from "satori" import fs from "fs" -import { ImageOptions, SocialImageOptions, getSatoriFont } from "../util/imageHelper" import sharp from "sharp" -import { defaultImage } from "../util/socialImage" +import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og" import { unescapeHTML } from "../util/escape" /** @@ -20,15 +19,12 @@ async function generateSocialImage( imageDir: string, ) { const fonts = await fontsPromise + const { width, height } = userOpts // JSX that will be used to generate satori svg - const imageElement = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData) + const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData) - const svg = await satori(imageElement, { - width: userOpts.width, - height: userOpts.height, - fonts: fonts, - }) + const svg = await satori(imageComponent, { width, height, fonts }) // Convert svg directly to webp (with additional compression) const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer() @@ -93,11 +89,11 @@ export default (() => { description = fileData.frontmatter?.description } - const imageDir = joinSegments(ctx.argv.output, "static", "social-images") + 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(imageDir)) { - fs.mkdirSync(imageDir, { recursive: true }) + if (!fs.existsSync(fileDir)) { + fs.mkdirSync(fileDir, { recursive: true }) } if (fileName) { @@ -107,14 +103,14 @@ export default (() => { title, description, fileName, - fileDir: imageDir, + fileDir, fileExt: extension, fontsPromise, cfg, fileData, }, fullOptions, - imageDir, + fileDir, ) } } @@ -128,8 +124,8 @@ export default (() => { const iconPath = joinSegments(baseDir, "static/icon.png") const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png` - // "static/social-images/filename.ext" - const ogImageGeneratedPath = `https://${cfg.baseUrl}/${imageDir.replace( + // "static/social-images/slug-filename.md.webp" + const ogImageGeneratedPath = `https://${cfg.baseUrl}/${fileDir.replace( `${ctx.argv.output}/`, "", )}/${fileName}.${extension}` diff --git a/quartz/util/imageHelper.ts b/quartz/util/og.tsx similarity index 67% rename from quartz/util/imageHelper.ts rename to quartz/util/og.tsx index 06e12cb7a..0430a265d 100644 --- a/quartz/util/imageHelper.ts +++ b/quartz/util/og.tsx @@ -1,7 +1,8 @@ import { FontWeight, SatoriOptions } from "satori/wasm" import { GlobalConfiguration } from "../cfg" -import { JSXInternal } from "preact/src/jsx" import { QuartzPluginData } from "../plugins/vfile" +import { JSXInternal } from "preact/src/jsx" +import { ThemeKey } from "./theme" /** * Get an array of `FontOptions` (for satori) given google font names @@ -61,7 +62,7 @@ export type SocialImageOptions = { /** * What color scheme to use for image generation (uses colors from config theme) */ - colorScheme: "lightMode" | "darkMode" + colorScheme: ThemeKey /** * Height to generate image with in pixels (should be around 630px) */ @@ -71,7 +72,7 @@ export type SocialImageOptions = { */ width: number /** - * Wether to use the auto generated image for the root path ("/", when set to false) or the default og image (when set to true). + * Whether to use the auto generated image for the root path ("/", when set to false) or the default og image (when set to true). */ excludeRoot: boolean /** @@ -130,3 +131,70 @@ export type ImageOptions = { */ fileData: QuartzPluginData } + +// This is the default template for generated social image. +export const defaultImage: SocialImageOptions["imageStructure"] = ( + cfg: GlobalConfiguration, + { colorScheme }: UserOpts, + title: string, + description: string, + fonts: SatoriOptions["fonts"], + _fileData: QuartzPluginData, +) => { + // How many characters are allowed before switching to smaller font + const fontBreakPoint = 22 + const useSmallerFont = title.length > fontBreakPoint + + // Setup to access image + const iconPath = `https://${cfg.baseUrl}/static/icon.png` + return ( +
+
+ +

+ {title} +

+
+

+ {description} +

+
+ ) +} diff --git a/quartz/util/socialImage.tsx b/quartz/util/socialImage.tsx deleted file mode 100644 index f830bcfad..000000000 --- a/quartz/util/socialImage.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { SatoriOptions } from "satori/wasm" -import { GlobalConfiguration } from "../cfg" -import { SocialImageOptions, UserOpts } from "./imageHelper" -import { QuartzPluginData } from "../plugins/vfile" -import { FullSlug, pathToRoot, joinSegments } from "./path" - -// This file contains the template of the default social image. - -export const defaultImage: SocialImageOptions["imageStructure"] = ( - cfg: GlobalConfiguration, - userOpts: UserOpts, - title: string, - description: string, - fonts: SatoriOptions["fonts"], - fileData: QuartzPluginData, -) => { - // How many characters are allowed before switching to smaller font - const fontBreakPoint = 22 - const useSmallerFont = title.length > fontBreakPoint - - const { colorScheme } = userOpts - - // Setup to access image - const iconPath = `https://${cfg.baseUrl}/static/icon.png` - return ( -
-
- -

- {title} -

-
-

- {description} -

-
- ) -}