metalsmith-optimize-images

July 25, 2026 · View on GitHub

Metalsmith plugin for generating responsive images with optimal formats

metalsmith:plugin npm: version license: MIT test coverage ESM

Breaking change in 0.12.0: When the persistent cache is enabled (cache: true), this plugin must now run before metalsmith-static-files in the pipeline, not after. The plugin writes variants to the source tree and the static-files plugin copies them to the build. See Usage for details.

Features

  • Multiple image formats: Generates AVIF and WebP variants with JPEG/PNG fallbacks
  • Responsive sizes: Creates different image sizes for various device widths
  • Persistent cache: Writes variants to a source-tree directory so subsequent builds (and CI) skip Sharp entirely
  • Background image support: Automatically processes unused images for CSS image-set() backgrounds
  • Progressive loading: Optional progressive image loading with low-quality placeholders
  • Lazy loading: Uses native browser lazy loading
  • Content-based hashing: Adds hash to filenames for optimal caching
  • Layout shift prevention: Adds width/height attributes
  • Parallel processing: Processes images in parallel
  • Metadata generation: Creates a JSON manifest with image information and variants
  • Configurable compression: Customize compression settings per format

Installation

npm install metalsmith-optimize-images

This package is ESM-only and requires Node >= 22. CommonJS projects on Node 22 can still load it, because Node 22 lets require import ESM packages.

Usage

When the persistent cache is enabled the plugin should run before the static-files copy so that newly generated variants land in the cache directory and get picked up by the copy step. When the cache is disabled and assets are copied by a pipeline plugin like metalsmith-static-files, the plugin should run after the copy. Sites whose images live under the Metalsmith source directory — including Metalsmith 2.7's statik() — need no ordering care: the plugin finds those images on disk itself.

With metalsmith.statik() (Metalsmith 2.7+)

Images under src/assets/ that are copied at build finalization by metalsmith.statik(['assets']) are resolved directly from the source directory. Generated variants are added to the build automatically:

metalsmith.statik(['assets']).use(
  optimizeImages({
    widths: [320, 640, 960, 1280, 1920],
    formats: ['avif', 'webp', 'original']
  })
);
metalsmith
  .use(
    optimizeImages({
      cache: true,
      widths: [320, 640, 960, 1280, 1920],
      formats: ['avif', 'webp', 'original']
      
    })
  )
  .use(
    assets({
      source: 'lib/assets/',
      destination: 'assets/'
    })
  );

Without cache (original behaviour)

metalsmith
  .use(
    assets({
      source: 'lib/assets/',
      destination: 'assets/'
    })
  )
  .use(
    optimizeImages({
      widths: [320, 640, 960, 1280, 1920],
      formats: ['avif', 'webp', 'original']
    })
  );

Theory of Operation

Understanding the full lifecycle helps explain why the plugin is structured the way it is and how the cache eliminates redundant work.

The problem

Sharp-based image processing is expensive. A typical site with 20 source images, five responsive widths, and three output formats generates 300 variant files. On a cold build this can take 30 seconds or more. On a CI host like Netlify the build starts from a clean checkout every time, so without intervention that cost is paid on every deploy even when no images changed.

How the cache solves it

The plugin can persist generated variants into a directory inside the source tree, for example lib/assets/images/responsive/. Because this directory is committed to git, CI clones already contain every variant that was generated on a previous build. The plugin checks the cache before calling Sharp: if a variant file already exists it is read from disk instead. Only genuinely new or changed images trigger Sharp processing.

Build pipeline flow

The cache changes where the plugin sits in the Metalsmith pipeline. Without the cache the plugin runs after the static-files copy because it needs images to be in the Metalsmith files object. With the cache enabled the plugin runs before the static-files copy:

Source images on disk (lib/assets/images/)


 ┌──────────────────────┐
 │  optimize-images      │  Reads source images from disk via sourcePrefix.
 │  (runs first)         │  Checks cache dir for existing variants.
 │                       │  Generates missing variants with Sharp.
 │                       │  Writes new variants to cache dir.
 │                       │  Rewrites HTML: <img> → <picture>.
 │                       │  Does NOT add variants to files object.
 └──────────────────────┘


 ┌──────────────────────┐
 │  metalsmith-static    │  Copies lib/assets/ → build/assets/.
 │  (runs second)        │  This includes the responsive/ cache dir,
 │                       │  so all variants end up in the build.
 └──────────────────────┘


   Final build output

Source image discovery

Each <img src> reference is resolved through a fixed lookup order; the first hit wins:

  1. The Metalsmith files object — the image is part of the file tree.
  2. The Metalsmith source directory — the image lives under metalsmith.source() but is excluded from the file tree, as with Metalsmith 2.7's statik(['assets']), which copies assets to the build only at finalization. An HTML reference to /assets/images/hero.jpg maps to src/assets/images/hero.jpg on disk.
  3. sourcePrefix — for legacy layouts whose assets live next to the source directory (e.g. lib/assets with metalsmith-static-files). The prefix is derived from the cache path: if cache resolves to lib/assets/images/responsive and outputDir is assets/images/responsive, the prefix is lib/, and assets/images/hero.jpg maps to lib/assets/images/hero.jpg.
  4. The build directory — a static-copy plugin already ran earlier in the pipeline.

The source directory is checked before the build directory on purpose: whatever a previous build left in build/ can never change the output, so clean and incremental builds emit identical markup.

If any references cannot be resolved, the plugin prints a warning to stdout listing the missed paths — a build where every lookup fails is not a normal build.

Cache invalidation

HTML images include a content hash in their filenames (e.g., hero-640w-a1b2c3d4.webp). When a source image changes, its hash changes, the expected filename differs from anything on disk, and the cache misses naturally. Old variants with the previous hash remain in the cache directory but are harmless — they simply stop being referenced in HTML.

Background images use deterministic filenames without hashes (e.g., hero-960w.webp) for easier CSS authoring. This means the cache cannot detect content changes for background images automatically. If a background source image changes content without changing its filename, delete the cache directory to force regeneration.

What gets committed to git

The cache directory (e.g., lib/assets/images/responsive/) should be committed to the repository. It contains only generated variant files — binary images that are a deterministic function of the source images and plugin configuration. Committing them trades repository size for build speed. A typical site adds 50-100 MB to the repo but saves 30+ seconds on every CI build.

Options

OptionTypeDefaultDescription
cacheboolean|stringfalsePersistent cache. true uses lib/<outputDir> (assumes a lib/ layout — pass a string path otherwise), string sets a custom path
widthsnumber[][320, 640, 960, 1280, 1920]Image sizes to generate
formatsstring[]['avif', 'webp', 'original']Image formats in order of preference
formatOptionsobjectSee belowFormat-specific compression settings
htmlPatternstring**/*.htmlGlob pattern to match HTML files
imgSelectorstringimg:not([data-no-responsive])CSS selector for images to process
outputDirstringassets/images/responsiveWhere to store the responsive images
outputPatternstring[filename]-[width]w-[hash].[format]Filename pattern with tokens
skipLargerbooleantrueDon't upscale images
lazybooleantrueUse native lazy loading
dimensionAttributesbooleantrueAdd width/height to prevent layout shift
sizesstring(max-width: 768px) 100vw, 75vwDefault sizes attribute
concurrencynumber5Process N images at a time
generateMetadatabooleanfalseGenerate a metadata JSON file at {outputDir}/responsive-images-manifest.json
isProgressivebooleanfalseEnable progressive image loading
placeholderobjectSee belowPlaceholder image settings
processUnusedImagesbooleantrueProcess unused images for background use
imageFolderstringlib/assets/imagesFolder scanned for background images, relative to metalsmith.source()
imagePatternstring**/*.{jpg,jpeg,png,gif,webp,avif}Glob pattern for images considered in background processing

Default Format Options

{
  avif: { quality: 65, speed: 5 },
  webp: { quality: 80, lossless: false },
  jpeg: { quality: 85, progressive: true },
  png: { compressionLevel: 8, palette: true }
}

Default Placeholder Options

{
  width: 50,      // Width of placeholder image
  quality: 30,    // Quality of placeholder image
  blur: 10        // Blur amount for placeholder
}

How It Works

Standard Mode (default)

The plugin operates in two phases:

Phase 1: HTML-Referenced Images

  1. Scans HTML files for image tags matching the configured selector
  2. Processes each image to create multiple sizes and formats using Sharp
  3. Creates a content hash for each image for cache-busting filenames
  4. Replaces <img> tags with responsive <picture> elements
  5. Adds width/height attributes to prevent layout shifts
  6. Implements native lazy loading for better performance

Phase 2: Background Images (when processUnusedImages: true)

  1. Finds images that weren't processed in Phase 1
  2. Generates 1x/2x variants (half size and original size) for retina displays
  3. Creates all configured formats (AVIF, WebP, original)
  4. Uses deterministic filenames without hashes for easier CSS authoring
  5. Suitable for use with CSS image-set() for background images

Progressive Mode (experimental)

When isProgressive: true is enabled:

  1. Generates low-quality placeholder images (small, blurred)
  2. Creates wrapper elements with both placeholder and high-resolution images
  3. Uses Intersection Observer to load high-resolution images on demand
  4. Implements smooth transitions between placeholder and final image
  5. Uses modern createImageBitmap() for reliable format detection (AVIF/WebP support)
  6. Maintains proper aspect ratios using original image dimensions
  7. Provides CSS and JavaScript for progressive loading behavior

Examples

Basic usage with defaults

metalsmith.use(optimizeImages());

With persistent cache

metalsmith.use(
  optimizeImages({
    cache: true
  })
);

This writes variants to lib/assets/images/responsive/ (derived from lib/ + the default outputDir). Commit this directory to git so CI builds skip Sharp entirely.

Custom cache path

metalsmith.use(
  optimizeImages({
    cache: 'lib/assets/images/responsive'
  })
);

Custom configuration

metalsmith.use(
  optimizeImages({
    // Generate fewer sizes
    widths: [480, 960, 1920],

    // Only use WebP and original format
    formats: ['webp', 'original'],

    // Custom quality settings
    formatOptions: {
      webp: { quality: 75, lossless: false },
      jpeg: { quality: 80, progressive: true }
    },

    // Custom selector for specific images
    imgSelector: 'img.responsive',

    // Custom output directory
    outputDir: 'images/processed',

    // Generate metadata manifest
    generateMetadata: true, // Creates images/processed/responsive-images-manifest.json

    // Don't add lazy loading
    lazy: false
  })
);

Progressive loading configuration

metalsmith.use(
  optimizeImages({
    // Enable progressive loading
    isProgressive: true,

    // Customize placeholder settings
    placeholder: {
      width: 40, // Smaller placeholder
      quality: 20, // Lower quality for faster loading
      blur: 15 // More blur for artistic effect
    },

    // Progressive mode works best with original format only
    formats: ['original']
  })
);

Excluding specific images

Add the data-no-responsive attribute to any image you don't want processed:

<img src="image.jpg" data-no-responsive alt="This image won't be processed" />

Supported File Types

The plugin automatically processes raster images and skips vector graphics:

Processed

  • JPEG (.jpg, .jpeg)
  • PNG (.png)
  • GIF (.gif)
  • WebP (.webp)
  • AVIF (.avif)

Automatically Skipped

  • SVG (.svg) — vector graphics that scale perfectly at any resolution
  • External URLs (http/https)
  • Data URLs (data:image/...)
  • Images with data-no-responsive attribute

Background Images

The plugin automatically processes images that aren't referenced in HTML for use as CSS background images. This feature is enabled by default (processUnusedImages: true).

How Background Processing Works

After processing HTML-referenced images, the plugin:

  1. Scans the Metalsmith files object and filesystem for all images
  2. Excludes already-processed images (those found during HTML scanning)
  3. Excludes responsive variants (generated images in the outputDir)
  4. Generates 1x/2x variants using actual image dimensions:
    • 1x variant: Half the original size for regular displays
    • 2x variant: Original image size for retina displays (sharper on high-DPI screens)
  5. Creates all formats (AVIF, WebP, original) for optimal browser support

Using Background Images with CSS

For an image like images/hero.jpg (1920x1080 pixels), the plugin generates variants like:

assets/images/responsive/hero-960w.avif   (1x - half 960px width for regular displays)
assets/images/responsive/hero-1920w.avif  (2x - original 1920px width, sharper on retina)
assets/images/responsive/hero-960w.webp   (1x - half 960px width for regular displays)
assets/images/responsive/hero-1920w.webp  (2x - original 1920px width, sharper on retina)
assets/images/responsive/hero-960w.jpg    (1x - half 960px width for regular displays)
assets/images/responsive/hero-1920w.jpg   (2x - original 1920px width, sharper on retina)

Background images are generated without hashes for easier CSS authoring. HTML images still include hashes for cache-busting.

Use them in CSS with image-set():

.hero {
  background-image: image-set(
    url('/assets/images/responsive/hero-960w.avif') 1x,
    url('/assets/images/responsive/hero-1920w.avif') 2x,
    url('/assets/images/responsive/hero-960w.webp') 1x,
    url('/assets/images/responsive/hero-1920w.webp') 2x,
    url('/assets/images/responsive/hero-960w.jpg') 1x,
    url('/assets/images/responsive/hero-1920w.jpg') 2x
  );
  background-size: cover;
  background-position: center;
}

Background Image Configuration

metalsmith.use(
  optimizeImages({
    widths: [320, 640, 960, 1280, 1920],
    formats: ['avif', 'webp', 'original'],
    processUnusedImages: true,
    generateMetadata: true
  })
);

Progressive Loading

Overview

Progressive loading provides a smooth user experience by:

  1. Showing a low-quality placeholder instantly
  2. Fading from placeholder to high-quality image
  3. Only loading high-resolution images when they enter the viewport
  4. Automatically serving the best supported format

Implementation

When progressive mode is enabled, the plugin:

  • Generates small, blurred placeholder images
  • Creates wrapper elements with proper aspect ratios
  • Includes JavaScript for intersection observer-based loading
  • Provides CSS for smooth transitions

HTML Output

Standard mode:

<picture>
  <source
    type="image/avif"
    srcset="image-320w.avif 320w, image-640w.avif 640w"
    sizes="(max-width: 768px) 100vw, 75vw"
  />
  <source
    type="image/webp"
    srcset="image-320w.webp 320w, image-640w.webp 640w"
    sizes="(max-width: 768px) 100vw, 75vw"
  />
  <img
    src="image-640w.jpg"
    srcset="image-320w.jpg 320w, image-640w.jpg 640w"
    sizes="(max-width: 768px) 100vw, 75vw"
    alt="Description"
    loading="lazy"
  />
</picture>

Progressive mode:

<div class="responsive-wrapper js-progressive-image-wrapper" style="aspect-ratio: 1280/720">
  <img class="low-res" src="/assets/images/responsive/image-placeholder.jpg" alt="Description" />
  <img class="high-res" src="" alt="Description" data-source="/assets/images/responsive/image-960w.jpg" />
</div>

CSS Requirements

The plugin provides CSS for progressive loading, but you can customize it:

.responsive-wrapper {
  position: relative;
  overflow: hidden;
  background-color: #f0f0f0;
}

.responsive-wrapper .low-res {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 0.4s ease;
}

.responsive-wrapper .high-res {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.4s ease;
}

.responsive-wrapper.done .high-res {
  opacity: 1;
}

.responsive-wrapper.done .low-res {
  opacity: 0;
}

Metadata Manifest

When generateMetadata: true is enabled, the plugin creates a JSON file at {outputDir}/responsive-images-manifest.json containing detailed information about all processed images:

{
  "images/hero.jpg": [
    {
      "path": "assets/images/responsive/hero-320w-a1b2c3d4.avif",
      "width": 320,
      "height": 180,
      "format": "avif",
      "size": 8432
    },
    {
      "path": "assets/images/responsive/hero-320w-a1b2c3d4.webp",
      "width": 320,
      "height": 180,
      "format": "webp",
      "size": 12658
    }
  ]
}

This manifest is useful for:

  • Debugging: Verify which variants were generated
  • Integration: Use variant information in other tools
  • Performance analysis: Compare file sizes across formats

Debug

To enable debug logs, set the DEBUG environment variable to metalsmith-optimize-images*:

metalsmith.env('DEBUG', 'metalsmith-optimize-images*');

CLI Usage

Metalsmith CLI

{
  "plugins": {
    "metalsmith-optimize-images": {
      "widths": [320, 640, 960, 1280, 1920],
      "formats": ["avif", "webp", "original"]
    }
  }
}

Test Coverage

93 tests covering all major functionality including unit tests for utilities, integration tests with real Metalsmith instances, cache persistence tests, build-determinism regression tests, and edge case coverage.

License

MIT

Development transparency

Portions of this project were developed with the assistance of AI tools including Claude and Claude Code. These tools were used to:

  • Generate or refactor code
  • Assist with documentation
  • Troubleshoot bugs and explore alternative approaches

All AI-assisted code has been reviewed and tested to ensure it meets project standards. See the included CLAUDE.md file for more details.