[Guide] How to use responsive images with Cloudinary and Casper 3

Hey!

So I’ve been setting up my new Ghost-powered site (not public yet) and something I struggled with recently is how to keep responsive images while using Cloudinary as a storage adapter.

The main issue is that the storage adapter returns only a single URL for the image, which means you can only define a static set of transformations to be applied by Cloudinary (eg. w_200, or a named transform like t_my_transform). This means that you lose the ability to dynamically change the resolution of the image in your template through the Handlebars tags and srcset (eg. {{img_url feature_image size="xl"}} would return the same URL as {{img_url feature_image size="s"}}).

So how to work around this? Well you have two main options.

Option 1: Use the Cloudinary Fetch API

The simplest option is to prepend your {{img_url ...}} tags with the Cloudinary Fetch API and whatever dynamic transforms you require.

https://res.cloudinary.com/demo/image/fetch/w_200/{{img_url feature_image}}

This works great, but it’s messy and you give up some functionality and caching becomes a little abstract.

Option 2a: Create a custom Handlebars helper for Cloudinary images

Ideally, the best option would be to create a custom Handlebars helper for processing the Cloudinary image URL and automatically injecting dynamic transformation options.

{{cloudinary_img_url feature_image width="200"}}

Unfortunately, it’s currently not possible to extend Handlebars within Ghost without modifying the core Ghost codebase in a variety of places.

Option 2b: Modify the {{img_url ...}} helper

While still requiring modification of the core files, this approach only requires changing a single file and doesn’t require much to be added. It also means you get to keep using {{img_url ...}} and it won’t impact any images that aren’t hosted on Cloudinary.

The first step is to modify /core/frontend/helpers/img_url.js and generally add the following:

// Override the external image check in imgUrl() and call a new function
const isInternalImage = detectInternalImage(requestedImageUrl);
if (!isInternalImage) {
  return getExternalImage(requestedImageUrl, options);
}

Add the new functions to check for Cloudinary and inject dynamic transforms:

function getExternalImage(requestedImageUrl, options) {
    if (detectCloudinaryImage) {
        return injectCloudinaryParameters(requestedImageUrl, options);
    }

    return requestedImageUrl;
}

function detectCloudinaryImage(requestedImageUrl) {
    return /cloudinary.com\/YOUR_CLOUDINARY_SITE/.test(requestedImageUrl);
}

function injectCloudinaryParameters(requestedImageUrl, options) {
    const parameters = options && options.hash && options.hash.cloudinary;

    if (!parameters) {
        return requestedImageUrl;
    }

    const defaultParam = 'YOUR_DEFAULT_CLOUDINARY_TRANSFORM';
    const newParams = [defaultParam, parameters].join('/');

    return requestedImageUrl.replace(defaultParam, newParams);
}

Here’s an example Gist of the entire modified img_url.js file for reference.

Make sure to change YOUR_CLOUDINARY_SITE to whatever site/slug you use in your Cloudinary URLs. Also, you’ll need to change YOUR_DEFAULT_CLOUDINARY_TRANSFORM to whatever default transform you set in the storage adapter. For me I set fetch.transformation to a named transform (so paste that named transform, t_something), but by default you might be using fetch.quality set to auto so paste in q_auto.

After this you need to restart Ghost.

The last step is to update the Casper templates to use the new parameters for Cloudinary images:

<img
  srcset="{{img_url feature_image cloudinary="w_300"}} 300w,
          {{img_url feature_image cloudinary="w_600"}} 600w,
          {{img_url feature_image cloudinary="w_1000"}} 1000w,
          {{img_url feature_image cloudinary="w_2000"}} 2000w"
  sizes="(max-width: 800px) 400px,
      (max-width: 1170px) 1170px,
          2000px"
  src="{{img_url feature_image cloudinary="w_2000"}}"
  alt="{{title}}"
/>

And that’s it! It’s not simple by any means, but it works. :ok_hand:

3 Likes

Since posting this I’ve release my site to the public, so you can see the results this approach in action:

Thanks, but as this need a core file changed, this mean that if we update, this crash, that is right?

Sure thing. You have to track changes on that files.

I’m working (just finished) on another problem with responsive images via Cloudinary - images in post. I have to change some core files, because I use Ghost as headless cms and don’t want to parse and replace urls in my frontend.

First of all, I added few lines to my config.development.json

"imageOptimization": {
    "cloudinary": {
      "baseUrl": "https://res.cloudinary.com/my_name/image/fetch/f_auto,q_auto"
    }
  }

Then I changed versions/4.9.4/node_modules/@tryghost/kg-default-cards/lib/utils/set-srcset-attribute.js:

if (isLocalContentImage(image.src, options.siteUrl)) {
        const [, imagesPath, filename] = image.src.match(/(.*\/content\/images)\/(.*)/);
        const srcs = [];

        srcsetWidths.forEach((width) => {
            if (width === image.width) {
                // use original image path if width matches exactly (avoids 302s from size->original)
                srcs.push(`${image.src} ${width}w`);
            } else if (width <= image.width) {
                // avoid creating srcset sizes larger than intrinsic image width
                if (options.imageOptimization.cloudinary) {
                    srcs.push(`${options.imageOptimization.cloudinary.baseUrl},w_${width}/${imagesPath}/${filename} ${width}w`);
                } else {
                    srcs.push(`${imagesPath}/size/w${width}/${filename} ${width}w`);
                }
            }
        });

        if (srcs.length) {
            elem.setAttribute('srcset', srcs.join(', '));
        }
    }

The main part here is where I check for cloudinary object and prepend Ghost image url with it. This will output proper srcset attribute with cloudinary images.

Last step I did, I changed versions/4.9.4/node_modules/@tryghost/kg-default-cards/lib/cards/image.js:

const { cloudinary } = options.imageOptimization;
const payloadSrc = cloudinary ? `${cloudinary.baseUrl}/${payload.src}` : payload.src;
img.setAttribute('src', payloadSrc);

This will output src for all images inside content helper.

This works fine for me (until I hit ghost update obviously). I wonder, if there is better way to achieve what I’m doing?