From df6432b0731d3cf3ac542545e1d6e438413c93fa Mon Sep 17 00:00:00 2001 From: Stephen Tse Date: Sat, 3 May 2025 13:47:29 -0700 Subject: [PATCH] Moved image element morphing to a transformer --- quartz.config.ts | 1 + quartz/plugins/emitters/assets.ts | 103 ++++++++++++++++--- quartz/plugins/transformers/images.ts | 142 ++++++++++++++++++++++++++ quartz/plugins/transformers/index.ts | 1 + quartz/plugins/transformers/ofm.ts | 32 ++---- 5 files changed, 245 insertions(+), 34 deletions(-) create mode 100644 quartz/plugins/transformers/images.ts diff --git a/quartz.config.ts b/quartz.config.ts index 1fb3fba72..d3bd90f8d 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -74,6 +74,7 @@ const config: QuartzConfig = { Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.Description(), Plugin.Latex({ renderEngine: "katex" }), + Plugin.Images(), ], filters: [Plugin.RemoveDrafts()], emitters: [ diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index 615d35930..2588bf0b0 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -7,15 +7,47 @@ 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 / GIFV 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 +/** + * Supported image extensions for optimization. + * + * Note that: + * - Sharp doesn't support BMP input out of the box: + * https://github.com/lovell/sharp/issues/543 + * - GIF / GIFV 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 + */ export const imageExtsToOptimize: ReadonlySet = new Set([".png", ".jpg", ".jpeg"]) -// Remember to also update sharp to use the right extension. + +/** + * Target optimized image file extension. + * + * Remember to also update sharp to use the right extension. + */ export const targetOptimizedImageExt = ".webp" +// Original image path -> preview image path +export const previewImageMap = new Map() + +export interface PreviewImageInfo { + /** + * Destination file slug for the preview image. + */ + slug: FullSlug + /** + * If true, only resize the image without format conversion. + */ + resizeOnly: boolean + /** + * If set, resize image to the specified width. + */ + width?: number + /** + * If set, resize image to the specified height. + */ + height?: number +} + 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]) @@ -27,29 +59,64 @@ const copyFile = async (argv: Argv, cfg: QuartzConfig, fp: FilePath) => { const srcExt = path.extname(fp).toLowerCase() const doOptimizeImage = cfg.configuration.optimizeImages && imageExtsToOptimize.has(srcExt) - const name = doOptimizeImage + const destSlug = doOptimizeImage ? ((slugifyFilePath(fp, true) + targetOptimizedImageExt) as FullSlug) : slugifyFilePath(fp) - const dest = joinSegments(argv.output, name) as FilePath + const dest = joinSegments(argv.output, destSlug) as FilePath // ensure dir exists const dir = path.dirname(dest) as FilePath await fs.promises.mkdir(dir, { recursive: true }) if (doOptimizeImage) { - await processImage(src, dest) + await processImage(argv, src, dest) } else { await fs.promises.copyFile(src, dest) } + + if (cfg.configuration.optimizeImages && previewImageMap.has(destSlug)) { + // Also output preview image + const previewImageInfo = previewImageMap.get(destSlug)! + const previewDest = joinSegments(argv.output, previewImageInfo.slug) as FilePath + + await processImage(argv, src, previewDest, previewImageInfo) + } + return dest } -async function processImage(src: FilePath, dest: FilePath) { +async function processImage( + argv: Argv, + src: FilePath, + dest: FilePath, + previewImageInfo?: PreviewImageInfo, +) { const originalFile = await fs.promises.readFile(src) - await sharp(originalFile, { animated: false }) - .webp({ quality: 90, smartSubsample: true, effort: 6 }) + let pipeline = sharp(originalFile, { animated: false }) + + if (previewImageInfo) { + pipeline = pipeline.resize( + // Give preview images a bit more pixels to improve resolution + previewImageInfo.width ? Math.round(previewImageInfo.width * 1.2) : undefined, + previewImageInfo.height ? Math.round(previewImageInfo.height * 1.2) : undefined, + { + fit: "cover", + position: "centre", + withoutEnlargement: true, // Don't upscale + fastShrinkOnLoad: true, + }, + ) + } + + if (!(previewImageInfo?.resizeOnly ?? false)) { + pipeline = pipeline.webp({ quality: 90, smartSubsample: true, effort: 6 }) // .avif({ quality: 90, effort: 9, chromaSubsampling: "4:2:0", bitdepth: 8 }) - .toFile(dest) + } + + // Write file + await pipeline.toFile( + previewImageInfo ? (joinSegments(argv.output, previewImageInfo.slug) as FilePath) : dest, + ) } export const Assets: QuartzEmitterPlugin = () => { @@ -76,6 +143,16 @@ export const Assets: QuartzEmitterPlugin = () => { : slugifyFilePath(changeEvent.path) const dest = joinSegments(ctx.argv.output, name) as FilePath await fs.promises.unlink(dest) + + // Remove preview image if exists + if (ctx.cfg.configuration.optimizeImages && previewImageMap.has(name)) { + const previewDest = joinSegments( + ctx.argv.output, + previewImageMap.get(name)!.slug, + ) as FilePath + await fs.promises.unlink(previewDest) + previewImageMap.delete(name) + } } } }, diff --git a/quartz/plugins/transformers/images.ts b/quartz/plugins/transformers/images.ts new file mode 100644 index 000000000..b836d7fb0 --- /dev/null +++ b/quartz/plugins/transformers/images.ts @@ -0,0 +1,142 @@ +import { PluggableList, Plugin, Transformer } from "unified" +import { QuartzTransformerPlugin } from "../types" +import { Element, Root as HtmlRoot } from "hast" +import { visit } from "unist-util-visit" +import { imageExtsToOptimize, previewImageMap, targetOptimizedImageExt } from "../emitters/assets" +import { FullSlug, getFileExtension, isAbsoluteURL, RelativeURL } from "../../util/path" +import { parseSelector } from "hast-util-parse-selector" + +export interface Options { +} + +const defaultOptions: Options = { +} + +/** + * File extensions of all supported image format. Files with an extension + * not on this list will not be recognized as images in wikilinks. + * + * @see ObsidianFlavoredMarkdown + */ +export const supportedImageExts: ReadonlySet = new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".gifv", + ".bmp", + ".svg", + ".webp", + ".avif", +]) + +/** + * Transformer for the `` HTML tag. + * + * Add this plugin after all Markdown parser plugins in quartz.config. + */ +export const Images: QuartzTransformerPlugin> = (userOpts) => { + const opts = { ...defaultOptions, ...userOpts } + + return { + name: "Images", + htmlPlugins: function (ctx) { + const plugins: PluggableList = [] + + if (ctx.cfg.configuration.optimizeImages) { + plugins.push(OptimizeImages) + } + + return plugins + }, + } +} + +/** + * HAST plugin that updates image tags of supported formats to serve + * optimized images. + * + * For example, given ``, it generates: + * + * ```html + * + * + * + * ``` + */ +const OptimizeImages: Plugin<[], HtmlRoot> = () => { + const transformer: Transformer = (tree: HtmlRoot) => { + visit(tree, "element", (node, index, parent) => { + if (node.tagName === "img" && typeof node.properties.src === "string") { + let src = node.properties.src as RelativeURL + if (isAbsoluteURL(src)) return // Skip External images + const ext = getFileExtension(src) + if (!ext || !supportedImageExts.has(ext)) return + // `data-slug` is set by the OFM markdown transformer. + // This is the absolute file path compared to `src`, which can be relative. + const fullSlug = node.properties["dataSlug"] as FullSlug + if (!fullSlug) return + + const width = + node.properties.width && node.properties.width !== "auto" + ? parseInt(node.properties.width as string) + : undefined + const height = + node.properties.height && node.properties.height !== "auto" + ? parseInt(node.properties.height as string) + : undefined + const shouldOptimizeImage = imageExtsToOptimize.has(ext) + + // If applicable, replace image extension with target extension + src = shouldOptimizeImage + ? (src.replace(new RegExp(`${ext}$`), targetOptimizedImageExt) as RelativeURL) + : src + node.properties.src = src + + // Replace original image source with preview image if custom dimension is defined + if (width || height) { + node.properties.src = src.replace( + new RegExp(`(?:${ext}|${targetOptimizedImageExt})$`), + `-preview${shouldOptimizeImage ? targetOptimizedImageExt : ext}`, + ) + const previewFileSlug = fullSlug.replace( + new RegExp(`${ext}$`), + `-preview${shouldOptimizeImage ? targetOptimizedImageExt : ext}`, + ) as FullSlug + node.properties.className = [ + "image-preview", + ...((node.properties.className ?? []) as string[]), + ] + + // Replace image node with a link wrapper + const wrapper: Element = parseSelector("a.image-link") + wrapper.properties.href = src + // No popover preview when hovering over image links + wrapper.properties["data-no-popover"] = true + // Disable SPA router click event that always forces link redirection + // (to make image links compatible with lightbox plugins) + wrapper.properties["data-router-ignore"] = true + wrapper.children = [node] + parent!.children[index!] = wrapper + + // Add preview image info to Assets emitter for image generation + const destSlug = fullSlug.replace( + new RegExp(`${ext}$`), + shouldOptimizeImage ? targetOptimizedImageExt : ext, + ) as FullSlug + previewImageMap.set(destSlug, { + width, + height, + resizeOnly: !shouldOptimizeImage, + slug: previewFileSlug, + }) + } + + // `data-slug` served its purpose, strip it + delete node.properties["dataSlug"] + } + }) + } + + return transformer +} \ No newline at end of file diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8e2cd844f..fe50e4b1d 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -11,3 +11,4 @@ export { SyntaxHighlighting } from "./syntax" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" export { RoamFlavoredMarkdown } from "./roam" +export { Images } from "./images" diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index abbc55511..b42fdf409 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 { FullSlug, splitAnchor } from "../../util/path" +import { splitAnchor } from "../../util/path" import { JSResource, CSSResource } from "../../util/resources" // @ts-ignore import calloutScript from "../../components/scripts/callout.inline" @@ -27,7 +27,7 @@ import { toHast } from "mdast-util-to-hast" import { toHtml } from "hast-util-to-html" import { capitalize } from "../../util/lang" import { PluggableList } from "unified" -import { imageExtsToOptimize, targetOptimizedImageExt } from "../emitters/assets" +import { supportedImageExts } from "./images" export interface Options { comments: boolean @@ -147,18 +147,6 @@ const wikilinkImageEmbedRegex = new RegExp( /^(?(?!^\d*x?\d*$).*?)?(\|?\s*?(?\d+)(x(?\d+))?)?$/, ) -const supportedImageExts: ReadonlySet = new Set([ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".gifv", - ".bmp", - ".svg", - ".webp", - ".avif", -]) - export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } @@ -219,7 +207,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> return src }, - markdownPlugins(ctx) { + markdownPlugins(_ctx) { const plugins: PluggableList = [] // regex replacements @@ -241,17 +229,15 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> if (value.startsWith("!")) { const ext: string = path.extname(fp).toLowerCase() let url = slugifyFilePath(fp as FilePath) + + // Handle images if (supportedImageExts.has(ext)) { - // Replace extension of eligible image files with target extension if image optimization is enabled. - url = - ctx.cfg.configuration.optimizeImages && imageExtsToOptimize.has(ext) - ? ((slugifyFilePath(fp as FilePath, true) + - targetOptimizedImageExt) as FullSlug) - : url const match = wikilinkImageEmbedRegex.exec(alias ?? "") const alt = match?.groups?.alt ?? "" const width = match?.groups?.width ?? "auto" const height = match?.groups?.height ?? "auto" + // Pass full slug to the HTML transformer "Images" + const fullSlug = slugifyFilePath(fp as FilePath, false) return { type: "image", url, @@ -260,14 +246,17 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> width, height, alt, + dataSlug: fullSlug, }, }, } + // Handle videos } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { return { type: "html", value: ``, } + // Handle audio } else if ( [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext) ) { @@ -275,6 +264,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> type: "html", value: ``, } + // Handle documents } else if ([".pdf"].includes(ext)) { return { type: "html",