mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-24 21:34:06 -06:00
Moved image element morphing to a transformer
This commit is contained in:
parent
7605f43f3f
commit
df6432b073
@ -74,6 +74,7 @@ const config: QuartzConfig = {
|
||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||
Plugin.Description(),
|
||||
Plugin.Latex({ renderEngine: "katex" }),
|
||||
Plugin.Images(),
|
||||
],
|
||||
filters: [Plugin.RemoveDrafts()],
|
||||
emitters: [
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
142
quartz/plugins/transformers/images.ts
Normal file
142
quartz/plugins/transformers/images.ts
Normal 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
|
||||
}
|
||||
@ -11,3 +11,4 @@ export { SyntaxHighlighting } from "./syntax"
|
||||
export { TableOfContents } from "./toc"
|
||||
export { HardLineBreaks } from "./linebreaks"
|
||||
export { RoamFlavoredMarkdown } from "./roam"
|
||||
export { Images } from "./images"
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user