The Trans Directory
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Jacky Zhao and committed by
GitHub
d9159e0a c005fe44

+960 -814
+3 -2
docs/advanced/making plugins.md
··· 25 25 - `BuildCtx` is defined in `quartz/ctx.ts`. It consists of 26 26 - `argv`: The command line arguments passed to the Quartz [[build]] command 27 27 - `cfg`: The full Quartz [[configuration]] 28 - - `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a `ServerSlug` is) 28 + - `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a slug is) 29 29 - `StaticResources` is defined in `quartz/resources.tsx`. It consists of 30 30 - `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. 31 31 - `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. 32 + - `additionalHead`: a list of JSX elements or functions that return JSX elements to be added to the `<head>` tag of the page. Functions receive the page's data as an argument and can conditionally render elements. 32 33 33 34 ## Transformers 34 35 ··· 234 235 // the build context 235 236 ctx: BuildCtx 236 237 // the name of the file to emit (not including the file extension) 237 - slug: ServerSlug 238 + slug: FullSlug 238 239 // the file extension 239 240 ext: `.${string}` | "" 240 241 // the file content to add
+6 -388
docs/features/social images.md
··· 2 2 title: "Social Media Preview Cards" 3 3 --- 4 4 5 - 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]]. 6 - 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`. 5 + 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). 6 + 7 + Quartz can also dynamically generate and use new cover images for every page to be used in link previews on social media for you. 7 8 8 9 ## Showcase 9 10 10 - After enabling `generateSocialImages` in `quartz.config.ts`, the social media link preview for [[authoring content | Authoring Content]] looks like this: 11 + After enabling the [[CustomOgImages]] emitter plugin, the social media link preview for [[authoring content | Authoring Content]] looks like this: 11 12 12 13 | Light | Dark | 13 14 | ----------------------------------- | ---------------------------------- | 14 15 | ![[social-image-preview-light.png]] | ![[social-image-preview-dark.png]] | 15 16 16 - 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]]). 17 - 18 - ## Customization 19 - 20 - You can customize how images will be generated in the quartz config. 21 - 22 - For example, here's what the default configuration looks like if you set `generateSocialImages: true`: 23 - 24 - ```typescript title="quartz.config.ts" 25 - generateSocialImages: { 26 - colorScheme: "lightMode", // what colors to use for generating image, same as theme colors from config, valid values are "darkMode" and "lightMode" 27 - width: 1200, // width to generate with (in pixels) 28 - height: 630, // height to generate with (in pixels) 29 - excludeRoot: false, // wether to exclude "/" index path to be excluded from auto generated images (false = use auto, true = use default og image) 30 - } 31 - ``` 32 - 33 - --- 34 - 35 - ### Frontmatter Properties 36 - 37 - > [!tip] Hint 38 - > 39 - > Overriding social media preview properties via frontmatter still works even if `generateSocialImages` is disabled. 40 - 41 - The following properties can be used to customize your link previews: 42 - 43 - | Property | Alias | Summary | 44 - | ------------------- | ---------------- | ----------------------------------- | 45 - | `socialDescription` | `description` | Description to be used for preview. | 46 - | `socialImage` | `image`, `cover` | Link to preview image. | 47 - 48 - The `socialImage` property should contain a link to an image relative to `quartz/static`. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`. 49 - 50 - > [!info] Info 51 - > 52 - > The priority for what image will be used for the cover image looks like the following: `frontmatter property > generated image (if enabled) > default image`. 53 - > 54 - > 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. 55 - 56 - --- 57 - 58 - ### Fully customized image generation 59 - 60 - 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. 61 - 62 - 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()`. 63 - 64 - > [!tip] Hint 65 - > 66 - > Satori only supports a subset of all valid CSS properties. All supported properties can be found in their [documentation](https://github.com/vercel/satori#css). 67 - 68 - Your custom image component should have the `SocialImageOptions["imageStructure"]` type, to make development easier for you. Using a component of this type, you will be passed the following variables: 69 - 70 - ```ts 71 - imageStructure: ( 72 - cfg: GlobalConfiguration, // global Quartz config (useful for getting theme colors and other info) 73 - userOpts: UserOpts, // options passed to `generateSocialImage` 74 - title: string, // title of current page 75 - description: string, // description of current page 76 - fonts: SatoriOptions["fonts"], // header + body font 77 - ) => JSXInternal.Element 78 - ``` 79 - 80 - Now, you can let your creativity flow and design your own image component! For reference and some cool tips, you can check how the markup for the default image looks. 81 - 82 - > [!example] Examples 83 - > 84 - > Here are some examples for markup you may need to get started: 85 - > 86 - > - Get a theme color 87 - > 88 - > `cfg.theme.colors[colorScheme].<colorName>`, where `<colorName>` corresponds to a key in `ColorScheme` (defined at the top of `quartz/util/theme.ts`) 89 - > 90 - > - Use the page title/description 91 - > 92 - > `<p>{title}</p>`/`<p>{description}</p>` 93 - > 94 - > - Use a font family 95 - > 96 - > Detailed in the Fonts chapter below 97 - 98 - --- 99 - 100 - ### Fonts 101 - 102 - You will also be passed an array containing a header and a body font (where the first entry is header and the second is body). The fonts matches the ones selected in `theme.typography.header` and `theme.typography.body` from `quartz.config.ts` and will be passed in the format required by [`satori`](https://github.com/vercel/satori). To use them in CSS, use the `.name` property (e.g. `fontFamily: fonts[1].name` to use the "body" font family). 103 - 104 - An example of a component using the header font could look like this: 105 - 106 - ```tsx title="socialImage.tsx" 107 - export const myImage: SocialImageOptions["imageStructure"] = (...) => { 108 - return <p style={{ fontFamily: fonts[0].name }}>Cool Header!</p> 109 - } 110 - ``` 111 - 112 - > [!example]- Local fonts 113 - > 114 - > For cases where you use a local fonts under `static` folder, make sure to set the correct `@font-face` in `custom.scss` 115 - > 116 - > ```scss title="custom.scss" 117 - > @font-face { 118 - > font-family: "Newsreader"; 119 - > font-style: normal; 120 - > font-weight: normal; 121 - > font-display: swap; 122 - > src: url("/static/Newsreader.woff2") format("woff2"); 123 - > } 124 - > ``` 125 - > 126 - > Then in `quartz/util/og.tsx`, you can load the satori fonts like so: 127 - > 128 - > ```tsx title="quartz/util/og.tsx" 129 - > const headerFont = joinSegments("static", "Newsreader.woff2") 130 - > const bodyFont = joinSegments("static", "Newsreader.woff2") 131 - > 132 - > export async function getSatoriFont(cfg: GlobalConfiguration): Promise<SatoriOptions["fonts"]> { 133 - > const headerWeight: FontWeight = 700 134 - > const bodyWeight: FontWeight = 400 135 - > 136 - > const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) 137 - > 138 - > const [header, body] = await Promise.all( 139 - > [headerFont, bodyFont].map((font) => 140 - > fetch(`${url.toString()}/${font}`).then((res) => res.arrayBuffer()), 141 - > ), 142 - > ) 143 - > 144 - > return [ 145 - > { name: cfg.theme.typography.header, data: header, weight: headerWeight, style: "normal" }, 146 - > { name: cfg.theme.typography.body, data: body, weight: bodyWeight, style: "normal" }, 147 - > ] 148 - > } 149 - > ``` 150 - > 151 - > This font then can be used with your custom structure 152 - 153 - ### Local testing 154 - 155 - 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/)). 156 - 157 - If you have `generateSocialImages` enabled, you can check out all generated images under `public/static/social-images`. 158 - 159 - ## Technical info 160 - 161 - Images will be generated as `.webp` files, which helps to keep images small (the average image takes ~`19kB`). They are also compressed further using [sharp](https://sharp.pixelplumbing.com/). 162 - 163 - When using images, the appropriate [Open Graph](https://ogp.me/) and [Twitter](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started) meta tags will be set to ensure they work and look as expected. 164 - 165 - ## Examples 166 - 167 - Besides the template for the default image generation (found under `quartz/util/og.tsx`), you can also add your own! To do this, you can either edit the source code of that file (not recommended) or create a new one (e.g. `customSocialImage.tsx`, source shown below). 168 - 169 - After adding that file, you can update `quartz.config.ts` to use your image generation template as follows: 170 - 171 - ```ts 172 - // Import component at start of file 173 - import { customImage } from "./quartz/util/customSocialImage.tsx" 174 - 175 - // In main config 176 - const config: QuartzConfig = { 177 - ... 178 - generateSocialImages: { 179 - ... 180 - imageStructure: customImage, // tells quartz to use your component when generating images 181 - }, 182 - } 183 - ``` 184 - 185 - The following example will generate images that look as follows: 186 - 187 - | Light | Dark | 188 - | ------------------------------------------ | ----------------------------------------- | 189 - | ![[custom-social-image-preview-light.png]] | ![[custom-social-image-preview-dark.png]] | 190 - 191 - This example (and the default template) use colors and fonts from your theme specified in the quartz config. Fonts get passed in as a prop, where `fonts[0]` will contain the header font and `fonts[1]` will contain the body font (more info in the [[#fonts]] section). 192 - 193 - ```tsx 194 - import { SatoriOptions } from "satori/wasm" 195 - import { GlobalConfiguration } from "../cfg" 196 - import { SocialImageOptions, UserOpts } from "./imageHelper" 197 - import { QuartzPluginData } from "../plugins/vfile" 198 - 199 - export const customImage: SocialImageOptions["imageStructure"] = ( 200 - cfg: GlobalConfiguration, 201 - userOpts: UserOpts, 202 - title: string, 203 - description: string, 204 - fonts: SatoriOptions["fonts"], 205 - fileData: QuartzPluginData, 206 - ) => { 207 - // How many characters are allowed before switching to smaller font 208 - const fontBreakPoint = 22 209 - const useSmallerFont = title.length > fontBreakPoint 17 + ## Configuration 210 18 211 - const { colorScheme } = userOpts 212 - return ( 213 - <div 214 - style={{ 215 - display: "flex", 216 - flexDirection: "row", 217 - justifyContent: "flex-start", 218 - alignItems: "center", 219 - height: "100%", 220 - width: "100%", 221 - }} 222 - > 223 - <div 224 - style={{ 225 - display: "flex", 226 - alignItems: "center", 227 - justifyContent: "center", 228 - height: "100%", 229 - width: "100%", 230 - backgroundColor: cfg.theme.colors[colorScheme].light, 231 - flexDirection: "column", 232 - gap: "2.5rem", 233 - paddingTop: "2rem", 234 - paddingBottom: "2rem", 235 - }} 236 - > 237 - <p 238 - style={{ 239 - color: cfg.theme.colors[colorScheme].dark, 240 - fontSize: useSmallerFont ? 70 : 82, 241 - marginLeft: "4rem", 242 - textAlign: "center", 243 - marginRight: "4rem", 244 - fontFamily: fonts[0].name, 245 - }} 246 - > 247 - {title} 248 - </p> 249 - <p 250 - style={{ 251 - color: cfg.theme.colors[colorScheme].dark, 252 - fontSize: 44, 253 - marginLeft: "8rem", 254 - marginRight: "8rem", 255 - lineClamp: 3, 256 - fontFamily: fonts[1].name, 257 - }} 258 - > 259 - {description} 260 - </p> 261 - </div> 262 - <div 263 - style={{ 264 - height: "100%", 265 - width: "2vw", 266 - position: "absolute", 267 - backgroundColor: cfg.theme.colors[colorScheme].tertiary, 268 - opacity: 0.85, 269 - }} 270 - /> 271 - </div> 272 - ) 273 - } 274 - ``` 275 - 276 - > [!example]- Advanced example 277 - > 278 - > The following example includes a customized social image with a custom background and formatted date. 279 - > 280 - > ```typescript title="custom-og.tsx" 281 - > export const og: SocialImageOptions["Component"] = ( 282 - > cfg: GlobalConfiguration, 283 - > fileData: QuartzPluginData, 284 - > { colorScheme }: Options, 285 - > title: string, 286 - > description: string, 287 - > fonts: SatoriOptions["fonts"], 288 - > ) => { 289 - > let created: string | undefined 290 - > let reading: string | undefined 291 - > if (fileData.dates) { 292 - > created = formatDate(getDate(cfg, fileData)!, cfg.locale) 293 - > } 294 - > const { minutes, text: _timeTaken, words: _words } = readingTime(fileData.text!) 295 - > reading = i18n(cfg.locale).components.contentMeta.readingTime({ 296 - > minutes: Math.ceil(minutes), 297 - > }) 298 - > 299 - > const Li = [created, reading] 300 - > 301 - > return ( 302 - > <div 303 - > style={{ 304 - > position: "relative", 305 - > display: "flex", 306 - > flexDirection: "row", 307 - > alignItems: "flex-start", 308 - > height: "100%", 309 - > width: "100%", 310 - > backgroundImage: `url("https://${cfg.baseUrl}/static/og-image.jpeg")`, 311 - > backgroundSize: "100% 100%", 312 - > }} 313 - > > 314 - > <div 315 - > style={{ 316 - > position: "absolute", 317 - > top: 0, 318 - > left: 0, 319 - > right: 0, 320 - > bottom: 0, 321 - > background: "radial-gradient(circle at center, transparent, rgba(0, 0, 0, 0.4) 70%)", 322 - > }} 323 - > /> 324 - > <div 325 - > style={{ 326 - > display: "flex", 327 - > height: "100%", 328 - > width: "100%", 329 - > flexDirection: "column", 330 - > justifyContent: "flex-start", 331 - > alignItems: "flex-start", 332 - > gap: "1.5rem", 333 - > paddingTop: "4rem", 334 - > paddingBottom: "4rem", 335 - > marginLeft: "4rem", 336 - > }} 337 - > > 338 - > <img 339 - > src={`"https://${cfg.baseUrl}/static/icon.jpeg"`} 340 - > style={{ 341 - > position: "relative", 342 - > backgroundClip: "border-box", 343 - > borderRadius: "6rem", 344 - > }} 345 - > width={80} 346 - > /> 347 - > <div 348 - > style={{ 349 - > display: "flex", 350 - > flexDirection: "column", 351 - > textAlign: "left", 352 - > fontFamily: fonts[0].name, 353 - > }} 354 - > > 355 - > <h2 356 - > style={{ 357 - > color: cfg.theme.colors[colorScheme].light, 358 - > fontSize: "3rem", 359 - > fontWeight: 700, 360 - > marginRight: "4rem", 361 - > fontFamily: fonts[0].name, 362 - > }} 363 - > > 364 - > {title} 365 - > </h2> 366 - > <ul 367 - > style={{ 368 - > color: cfg.theme.colors[colorScheme].gray, 369 - > gap: "1rem", 370 - > fontSize: "1.5rem", 371 - > fontFamily: fonts[1].name, 372 - > }} 373 - > > 374 - > {Li.map((item, index) => { 375 - > if (item) { 376 - > return <li key={index}>{item}</li> 377 - > } 378 - > })} 379 - > </ul> 380 - > </div> 381 - > <p 382 - > style={{ 383 - > color: cfg.theme.colors[colorScheme].light, 384 - > fontSize: "1.5rem", 385 - > overflow: "hidden", 386 - > marginRight: "8rem", 387 - > textOverflow: "ellipsis", 388 - > display: "-webkit-box", 389 - > WebkitLineClamp: 7, 390 - > WebkitBoxOrient: "vertical", 391 - > lineClamp: 7, 392 - > fontFamily: fonts[1].name, 393 - > }} 394 - > > 395 - > {description} 396 - > </p> 397 - > </div> 398 - > </div> 399 - > ) 400 - > } 401 - > ``` 19 + This functionality is provided by the [[CustomOgImages]] plugin. See the plugin page for customization options.
+360
docs/plugins/CustomOgImages.md
··· 1 + --- 2 + title: Custom OG Images 3 + tags: 4 + - feature/emitter 5 + --- 6 + 7 + The Custom OG Images emitter plugin generates social media preview images for your pages. It uses [satori](https://github.com/vercel/satori) to convert HTML/CSS into images, allowing you to create beautiful and consistent social media preview cards for your content. 8 + 9 + > [!note] 10 + > For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. 11 + 12 + ## Features 13 + 14 + - Automatically generates social media preview images for each page 15 + - Supports both light and dark mode themes 16 + - Customizable through frontmatter properties 17 + - Fallback to default image when needed 18 + - Full control over image design through custom components 19 + 20 + ## Configuration 21 + 22 + > [!info] Info 23 + > 24 + > The `baseUrl` property in your [[configuration]] must be set properly for social images to work correctly, as they require absolute paths. 25 + 26 + This plugin accepts the following configuration options: 27 + 28 + ```typescript title="quartz.config.ts" 29 + import { CustomOgImages } from "./quartz/plugins/emitters/ogImage" 30 + 31 + const config: QuartzConfig = { 32 + plugins: { 33 + emitters: [ 34 + CustomOgImages({ 35 + colorScheme: "lightMode", // what colors to use for generating image, same as theme colors from config, valid values are "darkMode" and "lightMode" 36 + width: 1200, // width to generate with (in pixels) 37 + height: 630, // height to generate with (in pixels) 38 + excludeRoot: false, // wether to exclude "/" index path to be excluded from auto generated images (false = use auto, true = use default og image) 39 + imageStructure: defaultImage, // custom image component to use 40 + }), 41 + ], 42 + }, 43 + } 44 + ``` 45 + 46 + ### Configuration Options 47 + 48 + | Option | Type | Default | Description | 49 + | ---------------- | --------- | ------------ | ----------------------------------------------------------------- | 50 + | `colorScheme` | string | "lightMode" | Theme to use for generating images ("darkMode" or "lightMode") | 51 + | `width` | number | 1200 | Width of the generated image in pixels | 52 + | `height` | number | 630 | Height of the generated image in pixels | 53 + | `excludeRoot` | boolean | false | Whether to exclude the root index page from auto-generated images | 54 + | `imageStructure` | component | defaultImage | Custom component to use for image generation | 55 + 56 + ## Frontmatter Properties 57 + 58 + The following properties can be used to customize your link previews: 59 + 60 + | Property | Alias | Summary | 61 + | ------------------- | ---------------- | ----------------------------------- | 62 + | `socialDescription` | `description` | Description to be used for preview. | 63 + | `socialImage` | `image`, `cover` | Link to preview image. | 64 + 65 + The `socialImage` property should contain a link to an image relative to `quartz/static`. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`. 66 + 67 + > [!info] Info 68 + > 69 + > The priority for what image will be used for the cover image looks like the following: `frontmatter property > generated image (if enabled) > default image`. 70 + > 71 + > The default image (`quartz/static/og-image.png`) will only be used as a fallback if nothing else is set. If the Custom OG Images emitter plugin 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. 72 + 73 + ## Customization 74 + 75 + You can fully customize how the images being generated look by passing your own component to `imageStructure`. This component takes JSX + 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 JSX looks like as a picture. This is ideal for prototyping your custom design. 76 + 77 + ### Fonts 78 + 79 + You will also be passed an array containing a header and a body font (where the first entry is header and the second is body). The fonts matches the ones selected in `theme.typography.header` and `theme.typography.body` from `quartz.config.ts` and will be passed in the format required by [`satori`](https://github.com/vercel/satori). To use them in CSS, use the `.name` property (e.g. `fontFamily: fonts[1].name` to use the "body" font family). 80 + 81 + An example of a component using the header font could look like this: 82 + 83 + ```tsx title="socialImage.tsx" 84 + export const myImage: SocialImageOptions["imageStructure"] = (...) => { 85 + return <p style={{ fontFamily: fonts[0].name }}>Cool Header!</p> 86 + } 87 + ``` 88 + 89 + > [!example]- Local fonts 90 + > 91 + > For cases where you use a local fonts under `static` folder, make sure to set the correct `@font-face` in `custom.scss` 92 + > 93 + > ```scss title="custom.scss" 94 + > @font-face { 95 + > font-family: "Newsreader"; 96 + > font-style: normal; 97 + > font-weight: normal; 98 + > font-display: swap; 99 + > src: url("/static/Newsreader.woff2") format("woff2"); 100 + > } 101 + > ``` 102 + > 103 + > Then in `quartz/util/og.tsx`, you can load the Satori fonts like so: 104 + > 105 + > ```tsx title="quartz/util/og.tsx" 106 + > import { joinSegments, QUARTZ } from "../path" 107 + > import fs from "fs" 108 + > import path from "path" 109 + > 110 + > const newsreaderFontPath = joinSegments(QUARTZ, "static", "Newsreader.woff2") 111 + > export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) { 112 + > // ... rest of implementation remains same 113 + > const fonts: SatoriOptions["fonts"] = [ 114 + > ...headerFontData.map((data, idx) => ({ 115 + > name: headerFontName, 116 + > data, 117 + > weight: headerWeights[idx], 118 + > style: "normal" as const, 119 + > })), 120 + > ...bodyFontData.map((data, idx) => ({ 121 + > name: bodyFontName, 122 + > data, 123 + > weight: bodyWeights[idx], 124 + > style: "normal" as const, 125 + > })), 126 + > { 127 + > name: "Newsreader", 128 + > data: await fs.promises.readFile(path.resolve(newsreaderFontPath)), 129 + > weight: 400, 130 + > style: "normal" as const, 131 + > }, 132 + > ] 133 + > 134 + > return fonts 135 + > } 136 + > ``` 137 + > 138 + > This font then can be used with your custom structure. 139 + 140 + ## Examples 141 + 142 + Here are some example image components you can use as a starting point: 143 + 144 + ### Basic Example 145 + 146 + This example will generate images that look as follows: 147 + 148 + | Light | Dark | 149 + | ------------------------------------------ | ----------------------------------------- | 150 + | ![[custom-social-image-preview-light.png]] | ![[custom-social-image-preview-dark.png]] | 151 + 152 + ```tsx 153 + import { SatoriOptions } from "satori/wasm" 154 + import { GlobalConfiguration } from "../cfg" 155 + import { SocialImageOptions, UserOpts } from "./imageHelper" 156 + import { QuartzPluginData } from "../plugins/vfile" 157 + 158 + export const customImage: SocialImageOptions["imageStructure"] = ( 159 + cfg: GlobalConfiguration, 160 + userOpts: UserOpts, 161 + title: string, 162 + description: string, 163 + fonts: SatoriOptions["fonts"], 164 + fileData: QuartzPluginData, 165 + ) => { 166 + // How many characters are allowed before switching to smaller font 167 + const fontBreakPoint = 22 168 + const useSmallerFont = title.length > fontBreakPoint 169 + 170 + const { colorScheme } = userOpts 171 + return ( 172 + <div 173 + style={{ 174 + display: "flex", 175 + flexDirection: "row", 176 + justifyContent: "flex-start", 177 + alignItems: "center", 178 + height: "100%", 179 + width: "100%", 180 + }} 181 + > 182 + <div 183 + style={{ 184 + display: "flex", 185 + alignItems: "center", 186 + justifyContent: "center", 187 + height: "100%", 188 + width: "100%", 189 + backgroundColor: cfg.theme.colors[colorScheme].light, 190 + flexDirection: "column", 191 + gap: "2.5rem", 192 + paddingTop: "2rem", 193 + paddingBottom: "2rem", 194 + }} 195 + > 196 + <p 197 + style={{ 198 + color: cfg.theme.colors[colorScheme].dark, 199 + fontSize: useSmallerFont ? 70 : 82, 200 + marginLeft: "4rem", 201 + textAlign: "center", 202 + marginRight: "4rem", 203 + fontFamily: fonts[0].name, 204 + }} 205 + > 206 + {title} 207 + </p> 208 + <p 209 + style={{ 210 + color: cfg.theme.colors[colorScheme].dark, 211 + fontSize: 44, 212 + marginLeft: "8rem", 213 + marginRight: "8rem", 214 + lineClamp: 3, 215 + fontFamily: fonts[1].name, 216 + }} 217 + > 218 + {description} 219 + </p> 220 + </div> 221 + <div 222 + style={{ 223 + height: "100%", 224 + width: "2vw", 225 + position: "absolute", 226 + backgroundColor: cfg.theme.colors[colorScheme].tertiary, 227 + opacity: 0.85, 228 + }} 229 + /> 230 + </div> 231 + ) 232 + } 233 + ``` 234 + 235 + ### Advanced Example 236 + 237 + The following example includes a customized social image with a custom background and formatted date: 238 + 239 + ```typescript title="custom-og.tsx" 240 + export const og: SocialImageOptions["Component"] = ( 241 + cfg: GlobalConfiguration, 242 + fileData: QuartzPluginData, 243 + { colorScheme }: Options, 244 + title: string, 245 + description: string, 246 + fonts: SatoriOptions["fonts"], 247 + ) => { 248 + let created: string | undefined 249 + let reading: string | undefined 250 + if (fileData.dates) { 251 + created = formatDate(getDate(cfg, fileData)!, cfg.locale) 252 + } 253 + const { minutes, text: _timeTaken, words: _words } = readingTime(fileData.text!) 254 + reading = i18n(cfg.locale).components.contentMeta.readingTime({ 255 + minutes: Math.ceil(minutes), 256 + }) 257 + 258 + const Li = [created, reading] 259 + 260 + return ( 261 + <div 262 + style={{ 263 + position: "relative", 264 + display: "flex", 265 + flexDirection: "row", 266 + alignItems: "flex-start", 267 + height: "100%", 268 + width: "100%", 269 + backgroundImage: `url("https://${cfg.baseUrl}/static/og-image.jpeg")`, 270 + backgroundSize: "100% 100%", 271 + }} 272 + > 273 + <div 274 + style={{ 275 + position: "absolute", 276 + top: 0, 277 + left: 0, 278 + right: 0, 279 + bottom: 0, 280 + background: "radial-gradient(circle at center, transparent, rgba(0, 0, 0, 0.4) 70%)", 281 + }} 282 + /> 283 + <div 284 + style={{ 285 + display: "flex", 286 + height: "100%", 287 + width: "100%", 288 + flexDirection: "column", 289 + justifyContent: "flex-start", 290 + alignItems: "flex-start", 291 + gap: "1.5rem", 292 + paddingTop: "4rem", 293 + paddingBottom: "4rem", 294 + marginLeft: "4rem", 295 + }} 296 + > 297 + <img 298 + src={`"https://${cfg.baseUrl}/static/icon.jpeg"`} 299 + style={{ 300 + position: "relative", 301 + backgroundClip: "border-box", 302 + borderRadius: "6rem", 303 + }} 304 + width={80} 305 + /> 306 + <div 307 + style={{ 308 + display: "flex", 309 + flexDirection: "column", 310 + textAlign: "left", 311 + fontFamily: fonts[0].name, 312 + }} 313 + > 314 + <h2 315 + style={{ 316 + color: cfg.theme.colors[colorScheme].light, 317 + fontSize: "3rem", 318 + fontWeight: 700, 319 + marginRight: "4rem", 320 + fontFamily: fonts[0].name, 321 + }} 322 + > 323 + {title} 324 + </h2> 325 + <ul 326 + style={{ 327 + color: cfg.theme.colors[colorScheme].gray, 328 + gap: "1rem", 329 + fontSize: "1.5rem", 330 + fontFamily: fonts[1].name, 331 + }} 332 + > 333 + {Li.map((item, index) => { 334 + if (item) { 335 + return <li key={index}>{item}</li> 336 + } 337 + })} 338 + </ul> 339 + </div> 340 + <p 341 + style={{ 342 + color: cfg.theme.colors[colorScheme].light, 343 + fontSize: "1.5rem", 344 + overflow: "hidden", 345 + marginRight: "8rem", 346 + textOverflow: "ellipsis", 347 + display: "-webkit-box", 348 + WebkitLineClamp: 7, 349 + WebkitBoxOrient: "vertical", 350 + lineClamp: 7, 351 + fontFamily: fonts[1].name, 352 + }} 353 + > 354 + {description} 355 + </p> 356 + </div> 357 + </div> 358 + ) 359 + } 360 + ```
-10
package-lock.json
··· 75 75 "quartz": "quartz/bootstrap-cli.mjs" 76 76 }, 77 77 "devDependencies": { 78 - "@types/cli-spinner": "^0.2.3", 79 78 "@types/d3": "^7.4.3", 80 79 "@types/hast": "^3.0.4", 81 80 "@types/js-yaml": "^4.0.9", ··· 1584 1583 "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", 1585 1584 "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", 1586 1585 "license": "MIT" 1587 - }, 1588 - "node_modules/@types/cli-spinner": { 1589 - "version": "0.2.3", 1590 - "resolved": "https://registry.npmjs.org/@types/cli-spinner/-/cli-spinner-0.2.3.tgz", 1591 - "integrity": "sha512-TMO6mWltW0lCu1de8DMRq9+59OP/tEjghS+rs8ZEQ2EgYP5yV3bGw0tS14TMyJGqFaoVChNvhkVzv9RC1UgX+w==", 1592 - "dev": true, 1593 - "dependencies": { 1594 - "@types/node": "*" 1595 - } 1596 1586 }, 1597 1587 "node_modules/@types/css-font-loading-module": { 1598 1588 "version": "0.0.12",
-1
package.json
··· 98 98 "yargs": "^17.7.2" 99 99 }, 100 100 "devDependencies": { 101 - "@types/cli-spinner": "^0.2.3", 102 101 "@types/d3": "^7.4.3", 103 102 "@types/hast": "^3.0.4", 104 103 "@types/js-yaml": "^4.0.9",
+1 -1
quartz.config.ts
··· 19 19 baseUrl: "quartz.jzhao.xyz", 20 20 ignorePatterns: ["private", "templates", ".obsidian"], 21 21 defaultDateType: "created", 22 - generateSocialImages: true, 23 22 theme: { 24 23 fontOrigin: "googleFonts", 25 24 cdnCaching: true, ··· 88 87 Plugin.Assets(), 89 88 Plugin.Static(), 90 89 Plugin.NotFoundPage(), 90 + Plugin.CustomOgImages(), 91 91 ], 92 92 }, 93 93 }
+32 -13
quartz/build.ts
··· 250 250 ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), 251 251 ) 252 252 253 - const emittedFps = await emitter.emit(ctx, files, staticResources) 254 - 255 - if (ctx.argv.verbose) { 256 - for (const file of emittedFps) { 257 - console.log(`[emit:${emitter.name}] ${file}`) 253 + const emitted = await emitter.emit(ctx, files, staticResources) 254 + if (Symbol.asyncIterator in emitted) { 255 + // Async generator case 256 + for await (const file of emitted) { 257 + emittedFiles++ 258 + if (ctx.argv.verbose) { 259 + console.log(`[emit:${emitter.name}] ${file}`) 260 + } 261 + } 262 + } else { 263 + // Array case 264 + emittedFiles += emitted.length 265 + if (ctx.argv.verbose) { 266 + for (const file of emitted) { 267 + console.log(`[emit:${emitter.name}] ${file}`) 268 + } 258 269 } 259 270 } 260 271 261 - emittedFiles += emittedFps.length 262 272 continue 263 273 } 264 274 ··· 280 290 .filter((file) => !toRemove.has(file)) 281 291 .map((file) => contentMap.get(file)!) 282 292 283 - const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources) 284 - 285 - if (ctx.argv.verbose) { 286 - for (const file of emittedFps) { 287 - console.log(`[emit:${emitter.name}] ${file}`) 293 + const emitted = await emitter.emit(ctx, upstreamContent, staticResources) 294 + if (Symbol.asyncIterator in emitted) { 295 + // Async generator case 296 + for await (const file of emitted) { 297 + emittedFiles++ 298 + if (ctx.argv.verbose) { 299 + console.log(`[emit:${emitter.name}] ${file}`) 300 + } 301 + } 302 + } else { 303 + // Array case 304 + emittedFiles += emitted.length 305 + if (ctx.argv.verbose) { 306 + for (const file of emitted) { 307 + console.log(`[emit:${emitter.name}] ${file}`) 308 + } 288 309 } 289 310 } 290 - 291 - emittedFiles += emittedFps.length 292 311 } 293 312 } 294 313
-4
quartz/cfg.ts
··· 61 61 * Quartz will avoid using this as much as possible and use relative URLs most of the time 62 62 */ 63 63 baseUrl?: string 64 - /** 65 - * Whether to generate social images (Open Graph and Twitter standard) for link previews 66 - */ 67 - generateSocialImages: boolean | Partial<SocialImageOptions> 68 64 theme: Theme 69 65 /** 70 66 * Allow to translate the date in the language of your choice.
+1 -1
quartz/components/Breadcrumbs.tsx
··· 102 102 103 103 // Add current slug to full path 104 104 currentPath = joinSegments(currentPath, slugParts[i]) 105 - const includeTrailingSlash = !isTagPath || i < 1 105 + const includeTrailingSlash = !isTagPath || i < slugParts.length - 1 106 106 107 107 // Format and add current crumb 108 108 const crumb = formatCrumb(
+25 -153
quartz/components/Head.tsx
··· 1 1 import { i18n } from "../i18n" 2 - import { FullSlug, joinSegments, pathToRoot } from "../util/path" 2 + import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path" 3 3 import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources" 4 - import { getFontSpecificationName, googleFontHref } from "../util/theme" 4 + import { googleFontHref } from "../util/theme" 5 5 import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" 6 - import satori, { SatoriOptions } from "satori" 7 - import { loadEmoji, getIconCode } from "../util/emoji" 8 - import fs from "fs" 9 - import sharp from "sharp" 10 - import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og" 11 6 import { unescapeHTML } from "../util/escape" 12 - 13 - /** 14 - * Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder 15 - * @param opts options for generating image 16 - */ 17 - async function generateSocialImage( 18 - { cfg, description, fileName, fontsPromise, title, fileData }: ImageOptions, 19 - userOpts: SocialImageOptions, 20 - imageDir: string, 21 - ) { 22 - const fonts = await fontsPromise 23 - const { width, height } = userOpts 24 - 25 - // JSX that will be used to generate satori svg 26 - const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData) 27 - 28 - const svg = await satori(imageComponent, { 29 - width, 30 - height, 31 - fonts, 32 - loadAdditionalAsset: async (languageCode: string, segment: string) => { 33 - if (languageCode === "emoji") { 34 - return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}` 35 - } 36 - 37 - return languageCode 38 - }, 39 - }) 40 - 41 - // Convert svg directly to webp (with additional compression) 42 - const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer() 43 - 44 - // Write to file system 45 - const filePath = joinSegments(imageDir, `${fileName}.${extension}`) 46 - fs.writeFileSync(filePath, compressed) 47 - } 48 - 49 - const extension = "webp" 50 - 51 - const defaultOptions: SocialImageOptions = { 52 - colorScheme: "lightMode", 53 - width: 1200, 54 - height: 630, 55 - imageStructure: defaultImage, 56 - excludeRoot: false, 57 - } 58 - 7 + import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage" 59 8 export default (() => { 60 - let fontsPromise: Promise<SatoriOptions["fonts"]> 61 - 62 - let fullOptions: SocialImageOptions 63 9 const Head: QuartzComponent = ({ 64 10 cfg, 65 11 fileData, 66 12 externalResources, 67 13 ctx, 68 14 }: QuartzComponentProps) => { 69 - // Initialize options if not set 70 - if (!fullOptions) { 71 - if (typeof cfg.generateSocialImages !== "boolean") { 72 - fullOptions = { ...defaultOptions, ...cfg.generateSocialImages } 73 - } else { 74 - fullOptions = defaultOptions 75 - } 76 - } 77 - 78 - // Memoize google fonts 79 - if (!fontsPromise && cfg.generateSocialImages) { 80 - const headerFont = getFontSpecificationName(cfg.theme.typography.header) 81 - const bodyFont = getFontSpecificationName(cfg.theme.typography.body) 82 - fontsPromise = getSatoriFont(headerFont, bodyFont) 83 - } 84 - 85 - const slug = fileData.filePath 86 - // since "/" is not a valid character in file names, replace with "-" 87 - const fileName = slug?.replaceAll("/", "-") 88 - 89 - // Get file description (priority: frontmatter > fileData > default) 90 - const fdDescription = 91 - fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description 92 15 const titleSuffix = cfg.pageTitleSuffix ?? "" 93 16 const title = 94 17 (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix 95 - let description = "" 96 - if (fdDescription) { 97 - description = unescapeHTML(fdDescription) 98 - } 99 - 100 - if (fileData.frontmatter?.socialDescription) { 101 - description = fileData.frontmatter?.socialDescription as string 102 - } else if (fileData.frontmatter?.description) { 103 - description = fileData.frontmatter?.description 104 - } 105 - 106 - const fileDir = joinSegments(ctx.argv.output, "static", "social-images") 107 - if (cfg.generateSocialImages) { 108 - // Generate folders for social images (if they dont exist yet) 109 - if (!fs.existsSync(fileDir)) { 110 - fs.mkdirSync(fileDir, { recursive: true }) 111 - } 112 - 113 - if (fileName) { 114 - // Generate social image (happens async) 115 - void generateSocialImage( 116 - { 117 - title, 118 - description, 119 - fileName, 120 - fileDir, 121 - fileExt: extension, 122 - fontsPromise, 123 - cfg, 124 - fileData, 125 - }, 126 - fullOptions, 127 - fileDir, 128 - ) 129 - } 130 - } 18 + const description = 19 + fileData.frontmatter?.socialDescription ?? 20 + fileData.frontmatter?.description ?? 21 + unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) 131 22 132 23 const { css, js, additionalHead } = externalResources 133 24 134 25 const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) 135 26 const path = url.pathname as FullSlug 136 27 const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!) 137 - 138 28 const iconPath = joinSegments(baseDir, "static/icon.png") 139 29 140 - const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png` 141 - // "static/social-images/slug-filename.md.webp" 142 - const ogImageGeneratedPath = `https://${cfg.baseUrl}/${fileDir.replace( 143 - `${ctx.argv.output}/`, 144 - "", 145 - )}/${fileName}.${extension}` 146 - 147 - // Use default og image if filePath doesnt exist (for autogenerated paths with no .md file) 148 - const useDefaultOgImage = fileName === undefined || !cfg.generateSocialImages 149 - 150 - // Path to og/social image (priority: frontmatter > generated image (if enabled) > default image) 151 - let ogImagePath = useDefaultOgImage ? ogImageDefaultPath : ogImageGeneratedPath 152 - 153 - // TODO: could be improved to support external images in the future 154 - // Aliases for image and cover handled in `frontmatter.ts` 155 - const frontmatterImgUrl = fileData.frontmatter?.socialImage 156 - 157 - // Override with default og image if config option is set 158 - if (fileData.slug === "index") { 159 - ogImagePath = ogImageDefaultPath 160 - } 161 - 162 - // Override with frontmatter url if existing 163 - if (frontmatterImgUrl) { 164 - ogImagePath = `https://${cfg.baseUrl}/static/${frontmatterImgUrl}` 165 - } 166 - 167 30 // Url of current page 168 31 const socialUrl = 169 32 fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!) 33 + 34 + const usesCustomOgImage = ctx.cfg.plugins.emitters.some( 35 + (e) => e.name === CustomOgImagesEmitterName, 36 + ) 37 + const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png` 170 38 171 39 return ( 172 40 <head> ··· 181 49 )} 182 50 <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" /> 183 51 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 184 - {/* OG/Twitter meta tags */} 52 + 185 53 <meta name="og:site_name" content={cfg.pageTitle}></meta> 186 54 <meta property="og:title" content={title} /> 187 55 <meta property="og:type" content="website" /> ··· 189 57 <meta name="twitter:title" content={title} /> 190 58 <meta name="twitter:description" content={description} /> 191 59 <meta property="og:description" content={description} /> 192 - <meta property="og:image:type" content={`image/${extension}`} /> 193 60 <meta property="og:image:alt" content={description} /> 194 - {/* Dont set width and height if unknown (when using custom frontmatter image) */} 195 - {!frontmatterImgUrl && ( 61 + 62 + {!usesCustomOgImage && ( 196 63 <> 197 - <meta property="og:image:width" content={fullOptions.width.toString()} /> 198 - <meta property="og:image:height" content={fullOptions.height.toString()} /> 64 + <meta property="og:image" content={ogImageDefaultPath} /> 65 + <meta property="og:image:url" content={ogImageDefaultPath} /> 66 + <meta name="twitter:image" content={ogImageDefaultPath} /> 67 + <meta 68 + property="og:image:type" 69 + content={`image/${getFileExtension(ogImageDefaultPath) ?? "png"}`} 70 + /> 199 71 </> 200 72 )} 201 - <meta property="og:image:url" content={ogImagePath} /> 73 + 202 74 {cfg.baseUrl && ( 203 75 <> 204 - <meta name="twitter:image" content={ogImagePath} /> 205 - <meta property="og:image" content={ogImagePath} /> 206 76 <meta property="twitter:domain" content={cfg.baseUrl}></meta> 207 77 <meta property="og:url" content={socialUrl}></meta> 208 78 <meta property="twitter:url" content={socialUrl}></meta> 209 79 </> 210 80 )} 81 + 211 82 <link rel="icon" href={iconPath} /> 212 83 <meta name="description" content={description} /> 213 84 <meta name="generator" content="Quartz" /> 85 + 214 86 {css.map((resource) => CSSResourceToStyleElement(resource, true))} 215 87 {js 216 88 .filter((resource) => resource.loadTime === "beforeDOMReady")
+7 -9
quartz/plugins/emitters/404.tsx
··· 31 31 async getDependencyGraph(_ctx, _content, _resources) { 32 32 return new DepGraph<FilePath>() 33 33 }, 34 - async emit(ctx, _content, resources): Promise<FilePath[]> { 34 + async *emit(ctx, _content, resources) { 35 35 const cfg = ctx.cfg.configuration 36 36 const slug = "404" as FullSlug 37 37 ··· 55 55 allFiles: [], 56 56 } 57 57 58 - return [ 59 - await write({ 60 - ctx, 61 - content: renderPage(cfg, slug, componentData, opts, externalResources), 62 - slug, 63 - ext: ".html", 64 - }), 65 - ] 58 + yield write({ 59 + ctx, 60 + content: renderPage(cfg, slug, componentData, opts, externalResources), 61 + slug, 62 + ext: ".html", 63 + }) 66 64 }, 67 65 } 68 66 }
+2 -7
quartz/plugins/emitters/aliases.ts
··· 18 18 19 19 return graph 20 20 }, 21 - async emit(ctx, content, _resources): Promise<FilePath[]> { 22 - const fps: FilePath[] = [] 23 - 21 + async *emit(ctx, content, _resources) { 24 22 for (const [_tree, file] of content) { 25 23 const ogSlug = simplifySlug(file.data.slug!) 26 24 27 25 for (const slug of file.data.aliases ?? []) { 28 26 const redirUrl = resolveRelative(slug, file.data.slug!) 29 - const fp = await write({ 27 + yield write({ 30 28 ctx, 31 29 content: ` 32 30 <!DOCTYPE html> ··· 43 41 slug, 44 42 ext: ".html", 45 43 }) 46 - 47 - fps.push(fp) 48 44 } 49 45 } 50 - return fps 51 46 }, 52 47 })
+2 -5
quartz/plugins/emitters/assets.ts
··· 33 33 34 34 return graph 35 35 }, 36 - async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { 36 + async *emit({ argv, cfg }, _content, _resources) { 37 37 const assetsPath = argv.output 38 38 const fps = await filesToCopy(argv, cfg) 39 - const res: FilePath[] = [] 40 39 for (const fp of fps) { 41 40 const ext = path.extname(fp) 42 41 const src = joinSegments(argv.directory, fp) as FilePath ··· 46 45 const dir = path.dirname(dest) as FilePath 47 46 await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists 48 47 await fs.promises.copyFile(src, dest) 49 - res.push(dest) 48 + yield dest 50 49 } 51 - 52 - return res 53 50 }, 54 51 } 55 52 }
+2 -2
quartz/plugins/emitters/cname.ts
··· 14 14 async getDependencyGraph(_ctx, _content, _resources) { 15 15 return new DepGraph<FilePath>() 16 16 }, 17 - async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { 17 + async emit({ argv, cfg }, _content, _resources) { 18 18 if (!cfg.configuration.baseUrl) { 19 19 console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")) 20 20 return [] ··· 24 24 if (!content) { 25 25 return [] 26 26 } 27 - fs.writeFileSync(path, content) 27 + await fs.promises.writeFile(path, content) 28 28 return [path] as FilePath[] 29 29 }, 30 30 })
+49 -60
quartz/plugins/emitters/componentResources.ts
··· 9 9 import popoverStyle from "../../components/styles/popover.scss" 10 10 import { BuildCtx } from "../../util/ctx" 11 11 import { QuartzComponent } from "../../components/types" 12 - import { googleFontHref, joinStyles } from "../../util/theme" 12 + import { googleFontHref, joinStyles, processGoogleFonts } from "../../util/theme" 13 13 import { Features, transform } from "lightningcss" 14 14 import { transform as transpile } from "esbuild" 15 15 import { write } from "./helpers" ··· 207 207 async getDependencyGraph(_ctx, _content, _resources) { 208 208 return new DepGraph<FilePath>() 209 209 }, 210 - async emit(ctx, _content, _resources): Promise<FilePath[]> { 211 - const promises: Promise<FilePath>[] = [] 210 + async *emit(ctx, _content, _resources) { 212 211 const cfg = ctx.cfg.configuration 213 212 // component specific scripts and styles 214 213 const componentResources = getComponentResources(ctx) ··· 217 216 // let the user do it themselves in css 218 217 } else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) { 219 218 // when cdnCaching is true, we link to google fonts in Head.tsx 220 - let match 219 + const response = await fetch(googleFontHref(ctx.cfg.configuration.theme)) 220 + googleFontsStyleSheet = await response.text() 221 221 222 - const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g 222 + if (!cfg.baseUrl) { 223 + throw new Error( 224 + "baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching", 225 + ) 226 + } 223 227 224 - googleFontsStyleSheet = await ( 225 - await fetch(googleFontHref(ctx.cfg.configuration.theme)) 226 - ).text() 228 + const { processedStylesheet, fontFiles } = await processGoogleFonts( 229 + googleFontsStyleSheet, 230 + cfg.baseUrl, 231 + ) 232 + googleFontsStyleSheet = processedStylesheet 227 233 228 - while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) { 229 - // match[0] is the `url(path)`, match[1] is the `path` 230 - const url = match[1] 231 - // the static name of this file. 232 - const [filename, ext] = url.split("/").pop()!.split(".") 233 - 234 - googleFontsStyleSheet = googleFontsStyleSheet.replace( 235 - url, 236 - `https://${cfg.baseUrl}/static/fonts/${filename}.ttf`, 237 - ) 234 + // Download and save font files 235 + for (const fontFile of fontFiles) { 236 + const res = await fetch(fontFile.url) 237 + if (!res.ok) { 238 + throw new Error(`failed to fetch font ${fontFile.filename}`) 239 + } 238 240 239 - promises.push( 240 - fetch(url) 241 - .then((res) => { 242 - if (!res.ok) { 243 - throw new Error(`Failed to fetch font`) 244 - } 245 - return res.arrayBuffer() 246 - }) 247 - .then((buf) => 248 - write({ 249 - ctx, 250 - slug: joinSegments("static", "fonts", filename) as FullSlug, 251 - ext: `.${ext}`, 252 - content: Buffer.from(buf), 253 - }), 254 - ), 255 - ) 241 + const buf = await res.arrayBuffer() 242 + yield write({ 243 + ctx, 244 + slug: joinSegments("static", "fonts", fontFile.filename) as FullSlug, 245 + ext: `.${fontFile.extension}`, 246 + content: Buffer.from(buf), 247 + }) 256 248 } 257 249 } 258 250 ··· 267 259 ...componentResources.css, 268 260 styles, 269 261 ) 262 + 270 263 const [prescript, postscript] = await Promise.all([ 271 264 joinScripts(componentResources.beforeDOMLoaded), 272 265 joinScripts(componentResources.afterDOMLoaded), 273 266 ]) 274 267 275 - promises.push( 276 - write({ 277 - ctx, 278 - slug: "index" as FullSlug, 279 - ext: ".css", 280 - content: transform({ 281 - filename: "index.css", 282 - code: Buffer.from(stylesheet), 283 - minify: true, 284 - targets: { 285 - safari: (15 << 16) | (6 << 8), // 15.6 286 - ios_saf: (15 << 16) | (6 << 8), // 15.6 287 - edge: 115 << 16, 288 - firefox: 102 << 16, 289 - chrome: 109 << 16, 290 - }, 291 - include: Features.MediaQueries, 292 - }).code.toString(), 293 - }), 294 - write({ 268 + yield write({ 269 + ctx, 270 + slug: "index" as FullSlug, 271 + ext: ".css", 272 + content: transform({ 273 + filename: "index.css", 274 + code: Buffer.from(stylesheet), 275 + minify: true, 276 + targets: { 277 + safari: (15 << 16) | (6 << 8), // 15.6 278 + ios_saf: (15 << 16) | (6 << 8), // 15.6 279 + edge: 115 << 16, 280 + firefox: 102 << 16, 281 + chrome: 109 << 16, 282 + }, 283 + include: Features.MediaQueries, 284 + }).code.toString(), 285 + }), 286 + yield write({ 295 287 ctx, 296 288 slug: "prescript" as FullSlug, 297 289 ext: ".js", 298 290 content: prescript, 299 291 }), 300 - write({ 292 + yield write({ 301 293 ctx, 302 294 slug: "postscript" as FullSlug, 303 295 ext: ".js", 304 296 content: postscript, 305 - }), 306 - ) 307 - 308 - return await Promise.all(promises) 297 + }) 309 298 }, 310 299 } 311 300 }
+19 -28
quartz/plugins/emitters/contentIndex.tsx
··· 117 117 118 118 return graph 119 119 }, 120 - async emit(ctx, content, _resources) { 120 + async *emit(ctx, content, _resources) { 121 121 const cfg = ctx.cfg.configuration 122 - const emitted: FilePath[] = [] 123 122 const linkIndex: ContentIndexMap = new Map() 124 123 for (const [tree, file] of content) { 125 124 const slug = file.data.slug! ··· 142 141 } 143 142 144 143 if (opts?.enableSiteMap) { 145 - emitted.push( 146 - await write({ 147 - ctx, 148 - content: generateSiteMap(cfg, linkIndex), 149 - slug: "sitemap" as FullSlug, 150 - ext: ".xml", 151 - }), 152 - ) 144 + yield write({ 145 + ctx, 146 + content: generateSiteMap(cfg, linkIndex), 147 + slug: "sitemap" as FullSlug, 148 + ext: ".xml", 149 + }) 153 150 } 154 151 155 152 if (opts?.enableRSS) { 156 - emitted.push( 157 - await write({ 158 - ctx, 159 - content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), 160 - slug: (opts?.rssSlug ?? "index") as FullSlug, 161 - ext: ".xml", 162 - }), 163 - ) 153 + yield write({ 154 + ctx, 155 + content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), 156 + slug: (opts?.rssSlug ?? "index") as FullSlug, 157 + ext: ".xml", 158 + }) 164 159 } 165 160 166 161 const fp = joinSegments("static", "contentIndex") as FullSlug ··· 175 170 }), 176 171 ) 177 172 178 - emitted.push( 179 - await write({ 180 - ctx, 181 - content: JSON.stringify(simplifiedIndex), 182 - slug: fp, 183 - ext: ".json", 184 - }), 185 - ) 186 - 187 - return emitted 173 + yield write({ 174 + ctx, 175 + content: JSON.stringify(simplifiedIndex), 176 + slug: fp, 177 + ext: ".json", 178 + }) 188 179 }, 189 180 externalResources: (ctx) => { 190 181 if (opts?.enableRSS) {
+2 -7
quartz/plugins/emitters/contentPage.tsx
··· 94 94 95 95 return graph 96 96 }, 97 - async emit(ctx, content, resources): Promise<FilePath[]> { 97 + async *emit(ctx, content, resources) { 98 98 const cfg = ctx.cfg.configuration 99 - const fps: FilePath[] = [] 100 99 const allFiles = content.map((c) => c[1].data) 101 100 102 101 let containsIndex = false ··· 118 117 } 119 118 120 119 const content = renderPage(cfg, slug, componentData, opts, externalResources) 121 - const fp = await write({ 120 + yield write({ 122 121 ctx, 123 122 content, 124 123 slug, 125 124 ext: ".html", 126 125 }) 127 - 128 - fps.push(fp) 129 126 } 130 127 131 128 if (!containsIndex && !ctx.argv.fastRebuild) { ··· 135 132 ), 136 133 ) 137 134 } 138 - 139 - return fps 140 135 }, 141 136 } 142 137 }
+2 -6
quartz/plugins/emitters/folderPage.tsx
··· 69 69 70 70 return graph 71 71 }, 72 - async emit(ctx, content, resources): Promise<FilePath[]> { 73 - const fps: FilePath[] = [] 72 + async *emit(ctx, content, resources) { 74 73 const allFiles = content.map((c) => c[1].data) 75 74 const cfg = ctx.cfg.configuration 76 75 ··· 119 118 } 120 119 121 120 const content = renderPage(cfg, slug, componentData, opts, externalResources) 122 - const fp = await write({ 121 + yield write({ 123 122 ctx, 124 123 content, 125 124 slug, 126 125 ext: ".html", 127 126 }) 128 - 129 - fps.push(fp) 130 127 } 131 - return fps 132 128 }, 133 129 } 134 130 }
+2 -1
quartz/plugins/emitters/helpers.ts
··· 2 2 import fs from "fs" 3 3 import { BuildCtx } from "../../util/ctx" 4 4 import { FilePath, FullSlug, joinSegments } from "../../util/path" 5 + import { Readable } from "stream" 5 6 6 7 type WriteOptions = { 7 8 ctx: BuildCtx 8 9 slug: FullSlug 9 10 ext: `.${string}` | "" 10 - content: string | Buffer 11 + content: string | Buffer | Readable 11 12 } 12 13 13 14 export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
+1
quartz/plugins/emitters/index.ts
··· 8 8 export { ComponentResources } from "./componentResources" 9 9 export { NotFoundPage } from "./404" 10 10 export { CNAME } from "./cname" 11 + export { CustomOgImages } from "./ogImage"
+134
quartz/plugins/emitters/ogImage.tsx
··· 1 + import { QuartzEmitterPlugin } from "../types" 2 + import { i18n } from "../../i18n" 3 + import { unescapeHTML } from "../../util/escape" 4 + import { FullSlug, getFileExtension } from "../../util/path" 5 + import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" 6 + import sharp from "sharp" 7 + import satori from "satori" 8 + import { loadEmoji, getIconCode } from "../../util/emoji" 9 + import { Readable } from "stream" 10 + import { write } from "./helpers" 11 + 12 + const defaultOptions: SocialImageOptions = { 13 + colorScheme: "lightMode", 14 + width: 1200, 15 + height: 630, 16 + imageStructure: defaultImage, 17 + excludeRoot: false, 18 + } 19 + 20 + /** 21 + * Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder 22 + * @param opts options for generating image 23 + */ 24 + async function generateSocialImage( 25 + { cfg, description, fonts, title, fileData }: ImageOptions, 26 + userOpts: SocialImageOptions, 27 + ): Promise<Readable> { 28 + const { width, height } = userOpts 29 + const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData) 30 + const svg = await satori(imageComponent, { 31 + width, 32 + height, 33 + fonts, 34 + loadAdditionalAsset: async (languageCode: string, segment: string) => { 35 + if (languageCode === "emoji") { 36 + return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}` 37 + } 38 + return languageCode 39 + }, 40 + }) 41 + 42 + return sharp(Buffer.from(svg)).webp({ quality: 40 }) 43 + } 44 + 45 + export const CustomOgImagesEmitterName = "CustomOgImages" 46 + export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => { 47 + const fullOptions = { ...defaultOptions, ...userOpts } 48 + 49 + return { 50 + name: CustomOgImagesEmitterName, 51 + getQuartzComponents() { 52 + return [] 53 + }, 54 + async *emit(ctx, content, _resources) { 55 + const cfg = ctx.cfg.configuration 56 + const headerFont = cfg.theme.typography.header 57 + const bodyFont = cfg.theme.typography.body 58 + const fonts = await getSatoriFonts(headerFont, bodyFont) 59 + 60 + for (const [_tree, vfile] of content) { 61 + // if this file defines socialImage, we can skip 62 + if (vfile.data.frontmatter?.socialImage !== undefined) { 63 + continue 64 + } 65 + 66 + const slug = vfile.data.slug! 67 + const titleSuffix = cfg.pageTitleSuffix ?? "" 68 + const title = 69 + (vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix 70 + const description = 71 + vfile.data.frontmatter?.socialDescription ?? 72 + vfile.data.frontmatter?.description ?? 73 + unescapeHTML( 74 + vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description, 75 + ) 76 + 77 + const stream = await generateSocialImage( 78 + { 79 + title, 80 + description, 81 + fonts, 82 + cfg, 83 + fileData: vfile.data, 84 + }, 85 + fullOptions, 86 + ) 87 + 88 + yield write({ 89 + ctx, 90 + content: stream, 91 + slug: `${slug}-og-image` as FullSlug, 92 + ext: ".webp", 93 + }) 94 + } 95 + }, 96 + externalResources: (ctx) => { 97 + if (!ctx.cfg.configuration.baseUrl) { 98 + return {} 99 + } 100 + 101 + const baseUrl = ctx.cfg.configuration.baseUrl 102 + return { 103 + additionalHead: [ 104 + (pageData) => { 105 + const isRealFile = pageData.filePath !== undefined 106 + const userDefinedOgImagePath = pageData.frontmatter?.socialImage 107 + const generatedOgImagePath = isRealFile 108 + ? `https://${baseUrl}/${pageData.slug!}-og-image.webp` 109 + : undefined 110 + const defaultOgImagePath = `https://${baseUrl}/static/og-image.png` 111 + const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath 112 + 113 + const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}` 114 + return ( 115 + <> 116 + {!userDefinedOgImagePath && ( 117 + <> 118 + <meta property="og:image:width" content={fullOptions.width.toString()} /> 119 + <meta property="og:image:height" content={fullOptions.height.toString()} /> 120 + </> 121 + )} 122 + 123 + <meta property="og:image" content={ogImagePath} /> 124 + <meta property="og:image:url" content={ogImagePath} /> 125 + <meta name="twitter:image" content={ogImagePath} /> 126 + <meta property="og:image:type" content={ogImageMimeType} /> 127 + </> 128 + ) 129 + }, 130 + ], 131 + } 132 + }, 133 + } 134 + }
+11 -6
quartz/plugins/emitters/static.ts
··· 3 3 import fs from "fs" 4 4 import { glob } from "../../util/glob" 5 5 import DepGraph from "../../depgraph" 6 + import { dirname } from "path" 6 7 7 8 export const Static: QuartzEmitterPlugin = () => ({ 8 9 name: "Static", ··· 20 21 21 22 return graph 22 23 }, 23 - async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { 24 + async *emit({ argv, cfg }, _content) { 24 25 const staticPath = joinSegments(QUARTZ, "static") 25 26 const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) 26 - await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { 27 - recursive: true, 28 - dereference: true, 29 - }) 30 - return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[] 27 + const outputStaticPath = joinSegments(argv.output, "static") 28 + await fs.promises.mkdir(outputStaticPath, { recursive: true }) 29 + for (const fp of fps) { 30 + const src = joinSegments(staticPath, fp) as FilePath 31 + const dest = joinSegments(outputStaticPath, fp) as FilePath 32 + await fs.promises.mkdir(dirname(dest), { recursive: true }) 33 + await fs.promises.copyFile(src, dest) 34 + yield dest 35 + } 31 36 }, 32 37 })
+2 -6
quartz/plugins/emitters/tagPage.tsx
··· 71 71 72 72 return graph 73 73 }, 74 - async emit(ctx, content, resources): Promise<FilePath[]> { 75 - const fps: FilePath[] = [] 74 + async *emit(ctx, content, resources) { 76 75 const allFiles = content.map((c) => c[1].data) 77 76 const cfg = ctx.cfg.configuration 78 77 ··· 127 126 } 128 127 129 128 const content = renderPage(cfg, slug, componentData, opts, externalResources) 130 - const fp = await write({ 129 + yield write({ 131 130 ctx, 132 131 content, 133 132 slug: file.data.slug!, 134 133 ext: ".html", 135 134 }) 136 - 137 - fps.push(fp) 138 135 } 139 - return fps 140 136 }, 141 137 } 142 138 }
+1
quartz/plugins/transformers/frontmatter.ts
··· 131 131 created: string 132 132 published: string 133 133 description: string 134 + socialDescription: string 134 135 publish: boolean | string 135 136 draft: boolean | string 136 137 lang: string
+5 -1
quartz/plugins/types.ts
··· 38 38 ) => QuartzEmitterPluginInstance 39 39 export type QuartzEmitterPluginInstance = { 40 40 name: string 41 - emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]> 41 + emit( 42 + ctx: BuildCtx, 43 + content: ProcessedContent[], 44 + resources: StaticResources, 45 + ): Promise<FilePath[]> | AsyncGenerator<FilePath> 42 46 /** 43 47 * Returns the components (if any) that are used in rendering the page. 44 48 * This helps Quartz optimize the page by only including necessary resources
+29 -12
quartz/processors/emit.ts
··· 4 4 import { QuartzLogger } from "../util/log" 5 5 import { trace } from "../util/trace" 6 6 import { BuildCtx } from "../util/ctx" 7 + import chalk from "chalk" 7 8 8 9 export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) { 9 10 const { argv, cfg } = ctx ··· 14 15 15 16 let emittedFiles = 0 16 17 const staticResources = getStaticResourcesFromPlugins(ctx) 17 - for (const emitter of cfg.plugins.emitters) { 18 - try { 19 - const emitted = await emitter.emit(ctx, content, staticResources) 20 - emittedFiles += emitted.length 21 - 22 - if (ctx.argv.verbose) { 23 - for (const file of emitted) { 24 - console.log(`[emit:${emitter.name}] ${file}`) 18 + await Promise.all( 19 + cfg.plugins.emitters.map(async (emitter) => { 20 + try { 21 + const emitted = await emitter.emit(ctx, content, staticResources) 22 + if (Symbol.asyncIterator in emitted) { 23 + // Async generator case 24 + for await (const file of emitted) { 25 + emittedFiles++ 26 + if (ctx.argv.verbose) { 27 + console.log(`[emit:${emitter.name}] ${file}`) 28 + } else { 29 + log.updateText(`Emitting output files: ${chalk.gray(file)}`) 30 + } 31 + } 32 + } else { 33 + // Array case 34 + emittedFiles += emitted.length 35 + for (const file of emitted) { 36 + if (ctx.argv.verbose) { 37 + console.log(`[emit:${emitter.name}] ${file}`) 38 + } else { 39 + log.updateText(`Emitting output files: ${chalk.gray(file)}`) 40 + } 41 + } 25 42 } 43 + } catch (err) { 44 + trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error) 26 45 } 27 - } catch (err) { 28 - trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error) 29 - } 30 - } 46 + }), 47 + ) 31 48 32 49 log.end(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince()}`) 33 50 }
+24 -7
quartz/util/log.ts
··· 1 - import { Spinner } from "cli-spinner" 1 + import readline from "readline" 2 2 3 3 export class QuartzLogger { 4 4 verbose: boolean 5 - spinner: Spinner | undefined 5 + private spinnerInterval: NodeJS.Timeout | undefined 6 + private spinnerText: string = "" 7 + private spinnerIndex: number = 0 8 + private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 9 + 6 10 constructor(verbose: boolean) { 7 11 this.verbose = verbose 8 12 } 9 13 10 14 start(text: string) { 15 + this.spinnerText = text 11 16 if (this.verbose) { 12 17 console.log(text) 13 18 } else { 14 - this.spinner = new Spinner(`%s ${text}`) 15 - this.spinner.setSpinnerString(18) 16 - this.spinner.start() 19 + this.spinnerIndex = 0 20 + this.spinnerInterval = setInterval(() => { 21 + readline.clearLine(process.stdout, 0) 22 + readline.cursorTo(process.stdout, 0) 23 + process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`) 24 + this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length 25 + }, 100) 17 26 } 18 27 } 19 28 29 + updateText(text: string) { 30 + this.spinnerText = text 31 + } 32 + 20 33 end(text?: string) { 21 - if (!this.verbose) { 22 - this.spinner!.stop(true) 34 + if (!this.verbose && this.spinnerInterval) { 35 + clearInterval(this.spinnerInterval) 36 + this.spinnerInterval = undefined 37 + readline.clearLine(process.stdout, 0) 38 + readline.cursorTo(process.stdout, 0) 23 39 } 40 + 24 41 if (text) { 25 42 console.log(text) 26 43 }
+203 -79
quartz/util/og.tsx
··· 1 + import { promises as fs } from "fs" 1 2 import { FontWeight, SatoriOptions } from "satori/wasm" 2 3 import { GlobalConfiguration } from "../cfg" 3 4 import { QuartzPluginData } from "../plugins/vfile" 4 5 import { JSXInternal } from "preact/src/jsx" 5 - import { ThemeKey } from "./theme" 6 + import { FontSpecification, ThemeKey } from "./theme" 7 + import path from "path" 8 + import { QUARTZ } from "./path" 9 + import { formatDate } from "../components/Date" 10 + import { getDate } from "../components/Date" 6 11 7 - /** 8 - * Get an array of `FontOptions` (for satori) given google font names 9 - * @param headerFontName name of google font used for header 10 - * @param bodyFontName name of google font used for body 11 - * @returns FontOptions for header and body 12 - */ 13 - export async function getSatoriFont(headerFontName: string, bodyFontName: string) { 14 - const headerWeight = 700 as FontWeight 15 - const bodyWeight = 400 as FontWeight 12 + const defaultHeaderWeight = [700] 13 + const defaultBodyWeight = [400] 14 + export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) { 15 + // Get all weights for header and body fonts 16 + const headerWeights: FontWeight[] = ( 17 + typeof headerFont === "string" 18 + ? defaultHeaderWeight 19 + : (headerFont.weights ?? defaultHeaderWeight) 20 + ) as FontWeight[] 21 + const bodyWeights: FontWeight[] = ( 22 + typeof bodyFont === "string" ? defaultBodyWeight : (bodyFont.weights ?? defaultBodyWeight) 23 + ) as FontWeight[] 16 24 17 - // Fetch fonts 18 - const headerFont = await fetchTtf(headerFontName, headerWeight) 19 - const bodyFont = await fetchTtf(bodyFontName, bodyWeight) 25 + const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name 26 + const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name 27 + 28 + // Fetch fonts for all weights 29 + const headerFontPromises = headerWeights.map((weight) => fetchTtf(headerFontName, weight)) 30 + const bodyFontPromises = bodyWeights.map((weight) => fetchTtf(bodyFontName, weight)) 31 + 32 + const [headerFontData, bodyFontData] = await Promise.all([ 33 + Promise.all(headerFontPromises), 34 + Promise.all(bodyFontPromises), 35 + ]) 20 36 21 37 // Convert fonts to satori font format and return 22 38 const fonts: SatoriOptions["fonts"] = [ 23 - { name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" }, 24 - { name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" }, 39 + ...headerFontData.map((data, idx) => ({ 40 + name: headerFontName, 41 + data, 42 + weight: headerWeights[idx], 43 + style: "normal" as const, 44 + })), 45 + ...bodyFontData.map((data, idx) => ({ 46 + name: bodyFontName, 47 + data, 48 + weight: bodyWeights[idx], 49 + style: "normal" as const, 50 + })), 25 51 ] 52 + 26 53 return fonts 27 54 } 28 55 ··· 32 59 * @param weight what font weight to fetch font 33 60 * @returns `.ttf` file of google font 34 61 */ 35 - async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> { 62 + export async function fetchTtf( 63 + fontName: string, 64 + weight: FontWeight, 65 + ): Promise<Buffer<ArrayBufferLike>> { 66 + const cacheKey = `${fontName.replaceAll(" ", "-")}-${weight}` 67 + const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts") 68 + const cachePath = path.join(cacheDir, cacheKey) 69 + 70 + // Check if font exists in cache 36 71 try { 37 - // Get css file from google fonts 38 - const cssResponse = await fetch( 39 - `https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`, 40 - ) 41 - const css = await cssResponse.text() 72 + await fs.access(cachePath) 73 + return fs.readFile(cachePath) 74 + } catch (error) { 75 + // ignore errors and fetch font 76 + } 42 77 43 - // Extract .ttf url from css file 44 - const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g 45 - const match = urlRegex.exec(css) 78 + // Get css file from google fonts 79 + const cssResponse = await fetch( 80 + `https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`, 81 + ) 82 + const css = await cssResponse.text() 46 83 47 - if (!match) { 48 - throw new Error("Could not fetch font") 49 - } 84 + // Extract .ttf url from css file 85 + const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g 86 + const match = urlRegex.exec(css) 50 87 51 - // Retrieve font data as ArrayBuffer 52 - const fontResponse = await fetch(match[1]) 88 + if (!match) { 89 + throw new Error("Could not fetch font") 90 + } 53 91 54 - // 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) 55 - const fontData = await fontResponse.arrayBuffer() 92 + // fontData is an ArrayBuffer containing the .ttf file data 93 + const fontResponse = await fetch(match[1]) 94 + const fontData = Buffer.from(await fontResponse.arrayBuffer()) 56 95 57 - return fontData 96 + try { 97 + await fs.mkdir(cacheDir, { recursive: true }) 98 + await fs.writeFile(cachePath, fontData) 58 99 } catch (error) { 59 - throw new Error(`Error fetching font: ${error}`) 100 + console.warn(`Failed to cache font: ${error}`) 101 + // Continue even if caching fails 60 102 } 103 + 104 + return fontData 61 105 } 62 106 63 107 export type SocialImageOptions = { ··· 109 153 */ 110 154 description: string 111 155 /** 112 - * what fileName to use when writing to disk 113 - */ 114 - fileName: string 115 - /** 116 - * what directory to store image in 117 - */ 118 - fileDir: string 119 - /** 120 - * what file extension to use (should be `webp` unless you also change sharp conversion) 121 - */ 122 - fileExt: string 123 - /** 124 156 * header + body font to be used when generating satori image (as promise to work around sync in component) 125 157 */ 126 - fontsPromise: Promise<SatoriOptions["fonts"]> 158 + fonts: SatoriOptions["fonts"] 127 159 /** 128 160 * `GlobalConfiguration` of quartz (used for theme/typography) 129 161 */ ··· 141 173 title: string, 142 174 description: string, 143 175 fonts: SatoriOptions["fonts"], 144 - _fileData: QuartzPluginData, 176 + fileData: QuartzPluginData, 145 177 ) => { 146 - const fontBreakPoint = 22 178 + const fontBreakPoint = 32 147 179 const useSmallerFont = title.length > fontBreakPoint 148 180 const iconPath = `https://${cfg.baseUrl}/static/icon.png` 149 181 182 + // Format date if available 183 + const rawDate = getDate(cfg, fileData) 184 + const date = rawDate ? formatDate(rawDate, cfg.locale) : null 185 + 186 + // Get tags if available 187 + const tags = fileData.frontmatter?.tags ?? [] 188 + 150 189 return ( 151 190 <div 152 191 style={{ 153 192 display: "flex", 154 193 flexDirection: "column", 155 - justifyContent: "center", 156 - alignItems: "center", 157 194 height: "100%", 158 195 width: "100%", 159 196 backgroundColor: cfg.theme.colors[colorScheme].light, 160 - gap: "2rem", 161 - padding: "1.5rem 5rem", 197 + padding: "2.5rem", 198 + fontFamily: fonts[1].name, 162 199 }} 163 200 > 201 + {/* Header Section */} 164 202 <div 165 203 style={{ 166 204 display: "flex", 167 205 alignItems: "center", 168 - width: "100%", 169 - flexDirection: "row", 170 - gap: "2.5rem", 206 + gap: "1rem", 207 + marginBottom: "0.5rem", 171 208 }} 172 209 > 173 - <img src={iconPath} width={135} height={135} /> 210 + <img 211 + src={iconPath} 212 + width={56} 213 + height={56} 214 + style={{ 215 + borderRadius: "50%", 216 + }} 217 + /> 174 218 <div 175 219 style={{ 176 220 display: "flex", 221 + fontSize: 32, 222 + color: cfg.theme.colors[colorScheme].gray, 223 + fontFamily: fonts[1].name, 224 + }} 225 + > 226 + {cfg.baseUrl} 227 + </div> 228 + </div> 229 + 230 + {/* Title Section */} 231 + <div 232 + style={{ 233 + display: "flex", 234 + marginTop: "1rem", 235 + marginBottom: "1.5rem", 236 + }} 237 + > 238 + <h1 239 + style={{ 240 + margin: 0, 241 + fontSize: useSmallerFont ? 64 : 72, 242 + fontFamily: fonts[0].name, 243 + fontWeight: 700, 177 244 color: cfg.theme.colors[colorScheme].dark, 178 - fontSize: useSmallerFont ? 70 : 82, 179 - fontFamily: fonts[0].name, 180 - maxWidth: "70%", 245 + lineHeight: 1.2, 246 + display: "-webkit-box", 247 + WebkitBoxOrient: "vertical", 248 + WebkitLineClamp: 2, 181 249 overflow: "hidden", 182 - textOverflow: "ellipsis", 183 250 }} 184 251 > 185 - <p 186 - style={{ 187 - margin: 0, 188 - overflow: "hidden", 189 - textOverflow: "ellipsis", 190 - whiteSpace: "nowrap", 191 - }} 192 - > 193 - {title} 194 - </p> 195 - </div> 252 + {title} 253 + </h1> 196 254 </div> 255 + 256 + {/* Description Section */} 197 257 <div 198 258 style={{ 199 259 display: "flex", 200 - color: cfg.theme.colors[colorScheme].dark, 201 - fontSize: 44, 202 - fontFamily: fonts[1].name, 203 - maxWidth: "100%", 204 - maxHeight: "40%", 205 - overflow: "hidden", 260 + flex: 1, 261 + fontSize: 36, 262 + color: cfg.theme.colors[colorScheme].darkgray, 263 + lineHeight: 1.4, 206 264 }} 207 265 > 208 266 <p ··· 210 268 margin: 0, 211 269 display: "-webkit-box", 212 270 WebkitBoxOrient: "vertical", 213 - WebkitLineClamp: 3, 271 + WebkitLineClamp: 4, 214 272 overflow: "hidden", 215 - textOverflow: "ellipsis", 216 273 }} 217 274 > 218 275 {description} 219 276 </p> 277 + </div> 278 + 279 + {/* Footer with Metadata */} 280 + <div 281 + style={{ 282 + display: "flex", 283 + alignItems: "center", 284 + justifyContent: "space-between", 285 + marginTop: "2rem", 286 + paddingTop: "2rem", 287 + borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`, 288 + }} 289 + > 290 + {/* Left side - Date */} 291 + <div 292 + style={{ 293 + display: "flex", 294 + alignItems: "center", 295 + color: cfg.theme.colors[colorScheme].gray, 296 + fontSize: 28, 297 + }} 298 + > 299 + {date && ( 300 + <div style={{ display: "flex", alignItems: "center" }}> 301 + <svg 302 + style={{ marginRight: "0.5rem" }} 303 + width="28" 304 + height="28" 305 + viewBox="0 0 24 24" 306 + fill="none" 307 + stroke="currentColor" 308 + > 309 + <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> 310 + <line x1="16" y1="2" x2="16" y2="6"></line> 311 + <line x1="8" y1="2" x2="8" y2="6"></line> 312 + <line x1="3" y1="10" x2="21" y2="10"></line> 313 + </svg> 314 + {date} 315 + </div> 316 + )} 317 + </div> 318 + 319 + {/* Right side - Tags */} 320 + <div 321 + style={{ 322 + display: "flex", 323 + gap: "0.5rem", 324 + flexWrap: "wrap", 325 + justifyContent: "flex-end", 326 + maxWidth: "60%", 327 + }} 328 + > 329 + {tags.slice(0, 3).map((tag: string) => ( 330 + <div 331 + style={{ 332 + display: "flex", 333 + padding: "0.5rem 1rem", 334 + backgroundColor: cfg.theme.colors[colorScheme].highlight, 335 + color: cfg.theme.colors[colorScheme].secondary, 336 + borderRadius: "10px", 337 + fontSize: 24, 338 + }} 339 + > 340 + #{tag} 341 + </div> 342 + ))} 343 + </div> 220 344 </div> 221 345 </div> 222 346 )
+4 -4
quartz/util/path.ts
··· 36 36 export function isRelativeURL(s: string): s is RelativeURL { 37 37 const validStart = /^\.{1,2}/.test(s) 38 38 const validEnding = !endsWith(s, "index") 39 - return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "") 39 + return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "") 40 40 } 41 41 42 42 export function getFullSlug(window: Window): FullSlug { ··· 61 61 62 62 export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { 63 63 fp = stripSlashes(fp) as FilePath 64 - let ext = _getFileExtension(fp) 64 + let ext = getFileExtension(fp) 65 65 const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") 66 66 if (excludeExt || [".md", ".html", undefined].includes(ext)) { 67 67 ext = "" ··· 272 272 } 273 273 274 274 function _hasFileExtension(s: string): boolean { 275 - return _getFileExtension(s) !== undefined 275 + return getFileExtension(s) !== undefined 276 276 } 277 277 278 - function _getFileExtension(s: string): string | undefined { 278 + export function getFileExtension(s: string): string | undefined { 279 279 return s.match(/\.[A-Za-z0-9]+$/)?.[0] 280 280 } 281 281
+31 -1
quartz/util/theme.ts
··· 15 15 darkMode: ColorScheme 16 16 } 17 17 18 - type FontSpecification = 18 + export type FontSpecification = 19 19 | string 20 20 | { 21 21 name: string ··· 88 88 const codeFont = formatFontSpecification("code", code) 89 89 90 90 return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap` 91 + } 92 + 93 + export interface GoogleFontFile { 94 + url: string 95 + filename: string 96 + extension: string 97 + } 98 + 99 + export async function processGoogleFonts( 100 + stylesheet: string, 101 + baseUrl: string, 102 + ): Promise<{ 103 + processedStylesheet: string 104 + fontFiles: GoogleFontFile[] 105 + }> { 106 + const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g 107 + const fontFiles: GoogleFontFile[] = [] 108 + let processedStylesheet = stylesheet 109 + 110 + let match 111 + while ((match = fontSourceRegex.exec(stylesheet)) !== null) { 112 + const url = match[1] 113 + const [filename, extension] = url.split("/").pop()!.split(".") 114 + const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}` 115 + 116 + processedStylesheet = processedStylesheet.replace(url, staticUrl) 117 + fontFiles.push({ url, filename, extension }) 118 + } 119 + 120 + return { processedStylesheet, fontFiles } 91 121 } 92 122 93 123 export function joinStyles(theme: Theme, ...stylesheet: string[]) {