diff --git a/docs/configuration.md b/docs/configuration.md index 4026c5197..6c72b9d49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,6 +57,7 @@ This part of the configuration concerns anything that can affect the whole site. - `tertiary`: hover states and visited [[graph view|graph]] nodes - `highlight`: internal link background, highlighted text, [[syntax highlighting|highlighted lines of code]] - `textHighlight`: markdown highlighted text background +- `optimizeImages`: whether to optimize images for web serving when building Quartz. If `true`, JPEG and PNG images will be stripped all metadata and converted to WebP format, and associated image links in [[wikilinks]] will be updated with the new file extension. ## Plugins diff --git a/docs/plugins/Assets.md b/docs/plugins/Assets.md index 47589b2c3..98602f0b0 100644 --- a/docs/plugins/Assets.md +++ b/docs/plugins/Assets.md @@ -4,7 +4,7 @@ tags: - plugin/emitter --- -This plugin emits all non-Markdown static assets in your content folder (like images, videos, HTML, etc). The plugin respects the `ignorePatterns` in the global [[configuration]]. +This plugin emits all non-Markdown static assets in your content folder (like images, videos, HTML, etc). The plugin respects the `ignorePatterns` and `optimizeImages` in the global [[configuration]]. Note that all static assets will then be accessible through its path on your generated site, i.e: `host.me/path/to/static.pdf` diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..1fb3fba72 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -52,6 +52,8 @@ const config: QuartzConfig = { }, }, }, + // Disable `optimizeImages` to speed up build time + optimizeImages: true, }, plugins: { transformers: [ diff --git a/quartz/cfg.ts b/quartz/cfg.ts index b5de75dd7..545d41842 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -70,6 +70,13 @@ export interface GlobalConfiguration { * Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 */ locale: ValidLocale + /** + * Whether to optimize images for web serving when building Quartz. + * + * If true, eligible images will be stripped all metadata and converted to WebP format, + * and associated image links in wikilinks will be updated with the new file extension. + */ + optimizeImages: boolean } export interface QuartzConfig { diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index d0da66ace..077ec94e2 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -1,48 +1,77 @@ -import { FilePath, joinSegments, slugifyFilePath } from "../../util/path" +import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import path from "path" import fs from "fs" import { glob } from "../../util/glob" import { Argv } from "../../util/ctx" import { QuartzConfig } from "../../cfg" +import sharp from "sharp" + +// Sharp doesn't support BMP input out of the box: +// https://github.com/lovell/sharp/issues/543 +// GIF processing can be very slow or ineffective (larger file size when CPU effort is +// set to a low number); only enable after testing: +// https://github.com/lovell/sharp/issues/3176 +const imageExtsToOptimize: Set = new Set([".png", ".jpg", ".jpeg"]) const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => { // glob all non MD files in content folder and copy it over return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) } -const copyFile = async (argv: Argv, fp: FilePath) => { +const copyFile = async (argv: Argv, cfg: QuartzConfig, fp: FilePath) => { const src = joinSegments(argv.directory, fp) as FilePath - const name = slugifyFilePath(fp) + const srcExt = path.extname(fp).toLowerCase() + const doOptimizeImage = cfg.configuration.optimizeImages && imageExtsToOptimize.has(srcExt) + + const name = doOptimizeImage + ? ((slugifyFilePath(fp, true) + ".webp") as FullSlug) + : slugifyFilePath(fp) const dest = joinSegments(argv.output, name) as FilePath // ensure dir exists const dir = path.dirname(dest) as FilePath await fs.promises.mkdir(dir, { recursive: true }) - await fs.promises.copyFile(src, dest) + if (doOptimizeImage) { + await processImage(src, dest) + } else { + await fs.promises.copyFile(src, dest) + } return dest } +async function processImage(src: FilePath, dest: FilePath) { + const originalFile = await fs.promises.readFile(src) + const convertedFile = await sharp(originalFile) + .webp({ quality: 90, smartSubsample: true, effort: 6 }) + .toBuffer() + await fs.promises.writeFile(dest, convertedFile) +} + export const Assets: QuartzEmitterPlugin = () => { return { name: "Assets", async *emit({ argv, cfg }) { const fps = await filesToCopy(argv, cfg) for (const fp of fps) { - yield copyFile(argv, fp) + yield copyFile(argv, cfg, fp) } }, async *partialEmit(ctx, _content, _resources, changeEvents) { for (const changeEvent of changeEvents) { - const ext = path.extname(changeEvent.path) + const ext = path.extname(changeEvent.path).toLowerCase() if (ext === ".md") continue if (changeEvent.type === "add" || changeEvent.type === "change") { - yield copyFile(ctx.argv, changeEvent.path) + yield copyFile(ctx.argv, ctx.cfg, changeEvent.path) } else if (changeEvent.type === "delete") { - const name = slugifyFilePath(changeEvent.path) + const doOptimizeImage = + ctx.cfg.configuration.optimizeImages && imageExtsToOptimize.has(ext) + const name = doOptimizeImage + ? ((slugifyFilePath(changeEvent.path, true) + ".webp") as FullSlug) + : slugifyFilePath(changeEvent.path) const dest = joinSegments(ctx.argv.output, name) as FilePath await fs.promises.unlink(dest) } diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index e958027ea..e829a0ada 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -13,7 +13,7 @@ import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util- import rehypeRaw from "rehype-raw" import { SKIP, visit } from "unist-util-visit" import path from "path" -import { splitAnchor } from "../../util/path" +import { FullSlug, splitAnchor } from "../../util/path" import { JSResource, CSSResource } from "../../util/resources" // @ts-ignore import calloutScript from "../../components/scripts/callout.inline" @@ -206,7 +206,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> return src }, - markdownPlugins(_ctx) { + markdownPlugins(ctx) { const plugins: PluggableList = [] // regex replacements @@ -227,8 +227,18 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> // embed cases if (value.startsWith("!")) { const ext: string = path.extname(fp).toLowerCase() - const url = slugifyFilePath(fp as FilePath) - if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { + let url = slugifyFilePath(fp as FilePath) + if ( + [".png", ".jpg", ".jpeg", ".gif", ".gifv", ".bmp", ".svg", ".webp"].includes( + ext, + ) + ) { + // Replace extension of eligible image files with ".webp" if image optimization is enabled. + url = + ctx.cfg.configuration.optimizeImages && + [".png", ".jpg", ".jpeg"].includes(ext) + ? ((slugifyFilePath(fp as FilePath, true) + ".webp") as FullSlug) + : url const match = wikilinkImageEmbedRegex.exec(alias ?? "") const alt = match?.groups?.alt ?? "" const width = match?.groups?.width ?? "auto"