Moved image element morphing to a transformer

This commit is contained in:
Stephen Tse 2025-05-03 13:47:29 -07:00
parent 7605f43f3f
commit df6432b073
5 changed files with 245 additions and 34 deletions

View File

@ -74,6 +74,7 @@ const config: QuartzConfig = {
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.Images(),
],
filters: [Plugin.RemoveDrafts()],
emitters: [

View File

@ -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<string> = 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<FullSlug, PreviewImageInfo>()
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)
}
}
}
},

View File

@ -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<string> = new Set([
".png",
".jpg",
".jpeg",
".gif",
".gifv",
".bmp",
".svg",
".webp",
".avif",
])
/**
* Transformer for the `<img>` HTML tag.
*
* Add this plugin after all Markdown parser plugins in quartz.config.
*/
export const Images: QuartzTransformerPlugin<Partial<Options>> = (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 `<img src="./image.png" />`, it generates:
*
* ```html
* <a href="./image.webp" class="preview-image-link" data-no-popover="true" data-router-ignore="true">
* <img src="./image-preview.webp" class="preview-image" />
* </a>
* ```
*/
const OptimizeImages: Plugin<[], HtmlRoot> = () => {
const transformer: Transformer<HtmlRoot> = (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
}

View File

@ -11,3 +11,4 @@ export { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc"
export { HardLineBreaks } from "./linebreaks"
export { RoamFlavoredMarkdown } from "./roam"
export { Images } from "./images"

View File

@ -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(
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
)
const supportedImageExts: ReadonlySet<string> = new Set([
".png",
".jpg",
".jpeg",
".gif",
".gifv",
".bmp",
".svg",
".webp",
".avif",
])
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
@ -219,7 +207,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
return src
},
markdownPlugins(ctx) {
markdownPlugins(_ctx) {
const plugins: PluggableList = []
// regex replacements
@ -241,17 +229,15 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
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 <image> transformer "Images"
const fullSlug = slugifyFilePath(fp as FilePath, false)
return {
type: "image",
url,
@ -260,14 +246,17 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
width,
height,
alt,
dataSlug: fullSlug,
},
},
}
// Handle videos
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
return {
type: "html",
value: `<video src="${url}" controls></video>`,
}
// Handle audio
} else if (
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
) {
@ -275,6 +264,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
type: "html",
value: `<audio src="${url}" controls></audio>`,
}
// Handle documents
} else if ([".pdf"].includes(ext)) {
return {
type: "html",