Table of Contents
Overview
In the previous post SEO Implementation, we covered search engine optimization. In this post, I’ll explain in detail the custom rehype plugin (rehype-picture.mjs) developed for image optimization, which is key to blog performance.
This plugin automatically converts <img> elements in markdown into <picture> elements to serve images in AVIF/WebP formats. For a broader perspective on image optimization, refer to the Lighthouse Performance Optimization Guide post.
Why a Custom Plugin
Astro has a built-in <Image> component, but it cannot be used in markdown content. There are two ways to insert images in markdown:
<!-- Method 1: Markdown syntax -->

<!-- Method 2: HTML img tag -->
<img src="/assets/images/example.jpg" alt="Alt text" />
In both cases, they render as standard <img> tags, so they can’t benefit from Astro’s <Image> component optimization. Therefore, I developed a rehype plugin that automatically optimizes images in the markdown processing pipeline.
How the Plugin Works
The overall flow of rehype-picture.mjs is as follows:
Markdown rendering
↓
rehype AST (HAST) generation
↓
rehype-picture plugin execution
↓
1. Search for <img> elements (HAST element + raw HTML)
2. Generate AVIF/WebP converted files from original images (Sharp)
3. Replace <img> with <picture>
↓
Final HTML output
Configuration in astro.config.mjs
To use the custom rehype-picture plugin, add it to the markdown.rehypePlugins option in astro.config.mjs.
markdown: {
rehypePlugins: [
[rehypePicture, { publicDir: new URL('./public', import.meta.url).pathname }],
],
},
The publicDir option specifies the actual path to the image files.
Image Conversion Logic
Let’s look at the core logic related to image conversion in rehype-picture.mjs.
Conversion Strategy by Format
The getVariants() function defined in rehype-picture.mjs determines which converted files to generate based on the original file extension.
function getVariants(ext) {
const lower = ext.toLowerCase();
switch (lower) {
case '.jpg':
case '.jpeg':
case '.png':
return [
{ ext: 'avif', format: 'avif', mime: 'image/avif', animated: false },
{ ext: 'webp', format: 'webp', mime: 'image/webp', animated: false },
];
case '.gif':
return [
{ ext: 'webp', format: 'webp', mime: 'image/webp', animated: true },
];
default:
return [];
}
}
| Original Format | Generated Format | Description |
|---|---|---|
| JPG/PNG | AVIF + WebP | Static images. AVIF is smaller but less compatible, so both provided |
| GIF | WebP | Animated GIFs are converted to animated WebP |
| SVG | - | Vector images, no conversion needed |
| External URL | - | Only local files are processed |
Conversion with Sharp
The actual image conversion uses the Sharp library. The ensureVariant() function generates AVIF or WebP converted files from the original image, and includes caching logic to skip if the file has already been generated.
async function ensureVariant(srcPath, destPath, format, isAnimated) {
const key = destPath;
if (generationCache.has(key)) return;
generationCache.add(key);
if (existsSync(destPath)) return;
try {
let pipeline = sharp(srcPath, { animated: isAnimated });
if (format === 'avif') {
pipeline = pipeline.avif({ quality: 50 });
} else if (format === 'webp') {
pipeline = pipeline.webp({ quality: 80 });
}
await pipeline.toFile(destPath);
} catch {
// Silently skip on failure (use original image)
}
}
- AVIF: Set to quality 50. AVIF shows minimal visual difference even at lower quality, allowing significant file size reduction
- WebP: Set to quality 80. A fallback for broader browser support
- animated: Preserves animation when converting GIF to WebP
When to Skip
Not all images need to be converted. The shouldSkip() function determines which images to exclude from conversion. External URLs can’t be converted since they’re not local files, data URIs are already inline data, and SVGs are vector images — converting them to raster formats would actually degrade quality.
function shouldSkip(src) {
if (!src) return true;
if (src.startsWith('http://') || src.startsWith('https://')) return true;
if (src.startsWith('data:')) return true;
if (src.toLowerCase().endsWith('.svg')) return true;
return false;
}
How the Plugin Finds and Converts Images in Markdown
The rehype plugin works with the result of converting markdown to HTML as a tree structure. This tree is called HAST (Hypertext Abstract Syntax Tree), where each HTML tag is represented as a JavaScript object (node). The plugin traverses this tree, finds <img> nodes, and replaces them with <picture>.
Since images in markdown are represented in the tree in two different ways, each requires different handling.
Case 1: Images Written in Markdown Syntax
Images written with  syntax are parsed as element-type <img> nodes in the HAST tree. In this case, we can directly access the node’s attributes (properties.src) for processing.
if (node.type === 'element' && node.tagName === 'img') {
// Skip if already inside a <picture>
if (parent?.type === 'element' && parent.tagName === 'picture') return;
const src = node.properties?.src;
// ... conversion processing
parent.children[idx] = createPictureNode(node, result);
}
Case 2: Images Written Directly in HTML
When <img src="..."> tags are written directly in markdown, they remain as unparsed raw string nodes in the HAST tree. Since we can’t directly access node attributes in this case, we use regular expressions to extract <img> tags and process them through string replacement.
if (node.type === 'raw' && typeof node.value === 'string') {
// Extract <img> tags with regex
const imgMatches = [...node.value.matchAll(IMG_TAG_RE)];
// ... convert to <picture> via string replacement
}
By handling both cases, all images are automatically optimized regardless of whether they’re written in markdown syntax or HTML tags.
Conversion Result
Before conversion:
<img src="/assets/images/example.jpg" alt="Example" />
After conversion:
<picture>
<source srcset="/assets/images/example.avif" type="image/avif" />
<source srcset="/assets/images/example.webp" type="image/webp" />
<img
src="/assets/images/example.jpg"
alt="Example"
width="800"
height="600"
loading="lazy"
decoding="async"
/>
</picture>
The browser checks <source> elements from top to bottom and uses the first supported format. If it supports AVIF, it loads AVIF; otherwise WebP; if neither is supported, it loads the original JPG.
Build Performance Optimization
Since image conversion is a CPU-intensive task, two caching strategies are used for build performance.
Dimension Cache
const dimensionCache = new Map();
async function getDimensions(filePath) {
if (dimensionCache.has(filePath)) return dimensionCache.get(filePath);
const meta = await sharp(filePath).metadata();
const dims = { width: meta.width, height: meta.height };
dimensionCache.set(filePath, dims);
return dims;
}
Caches image dimension information in memory to avoid repeated lookups. This is especially effective in a multilingual blog where the same image is used across 3 language files.
Generation Cache
const generationCache = new Set();
async function ensureVariant(srcPath, destPath, format, isAnimated) {
const key = destPath;
if (generationCache.has(key)) return; // Already requested
generationCache.add(key);
if (existsSync(destPath)) return; // File already exists
// ... execute conversion
}
Two levels of caching prevent unnecessary conversions:
generationCache: Checks if the conversion was already requested in the same build (memory)existsSync: Checks if the file was already generated in a previous build (filesystem)
LCP Image Preload
In Head.astro, the post’s featured image is preloaded as the LCP (Largest Contentful Paint) image.
{image && <link rel="preload" as="image" href="{image}" fetchpriority="high" />}
Since the featured image is the largest content element on the page, preloading it reduces LCP time. fetchpriority="high" tells the browser that this resource has high priority.
For the remaining images, the plugin automatically adds loading="lazy" and decoding="async" to prevent them from affecting initial load.
Conclusion
In this post, we looked at the custom rehype-picture plugin for the Astro blog.
- Automatically converts markdown
<img>elements to<picture>elements - Generates AVIF (quality 50) / WebP (quality 80) images using
Sharp - Handles both markdown syntax images and directly written HTML images
- Optimizes build performance with
dimensionCacheandgenerationCache - Improves loading performance with LCP image preload and lazy loading
In the next post Comments System and Ad Integration, we’ll cover how to integrate the Utterances comment system and Google AdSense ads.
Series Guide
This post is part of the Jekyll to Astro migration series.
- Why I Migrated from Jekyll to Astro
- Astro Installation and Project Setup
- Content Collections and Markdown Migration
- Multilingual (i18n) Implementation
- SEO Implementation
- Image Optimization — Custom rehype Plugin
- Comment System (Utterances)
- Ad Integration (Google AdSense)
- Search Implementation with Pagefind
- Layout and Component Architecture
- GitHub Pages Deployment
- Social Share Automation Script
- Troubleshooting and Tips
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.