class Jekyll::Cloudinary::CloudinaryTag

Public Class Methods

new(tag_name, markup, tokens) click to toggle source
Calls superclass method
# File lib/jekyll/cloudinary.rb, line 9
def initialize(tag_name, markup, tokens)
  @markup = markup
  super
end

Public Instance Methods

render(context) click to toggle source
# File lib/jekyll/cloudinary.rb, line 14
def render(context)
  # Default settings
  settings_defaults = {
    "cloud_name"         => "",
    "only_prod"          => false,
    "verbose"            => false,
  }
  preset_defaults = {
    "min_width"          => 320,
    "max_width"          => 1200,
    "fallback_max_width" => 1200,
    "steps"              => 5,
    "sizes"              => "100vw",
    "figure"             => "auto",
    "attributes"         => {},
    "width_height"       => true,
    # Cloudinary transformations
    "height"             => false,
    "crop"               => "limit",
    "aspect_ratio"       => false,
    "gravity"            => false,
    "zoom"               => false,
    "x"                  => false,
    "y"                  => false,
    "format"             => false,
    "fetch_format"       => "auto",
    "quality"            => "auto",
    "radius"             => false,
    "angle"              => false,
    "effect"             => false,
    "opacity"            => false,
    "border"             => false,
    "background"         => false,
    "overlay"            => false,
    "underlay"           => false,
    "default_image"      => false,
    "delay"              => false,
    "color"              => false,
    "color_space"        => false,
    "dpr"                => false,
    "page"               => false,
    "density"            => false,
    "flags"              => false,
    "transformation"     => false,
  }

  # TODO: Add validation for this parameters
  transformation_options = {
    "height"         => "h",
    "crop"           => "c", # can include add-on: imagga_scale
    "aspect_ratio"   => "ar",
    "gravity"        => "g",
    "zoom"           => "z",
    "x"              => "x",
    "y"              => "y",
    "fetch_format"   => "f",
    "quality"        => "q", # can include add-on: jpegmini
    "radius"         => "r",
    "angle"          => "a",
    "effect"         => "e", # can include add-on: viesus_correct
    "opacity"        => "o",
    "border"         => "bo",
    "background"     => "b",
    "overlay"        => "l",
    "underlay"       => "u",
    "default_image"  => "d",
    "delay"          => "dl",
    "color"          => "co",
    "color_space"    => "cs",
    "dpr"            => "dpr",
    "page"           => "pg",
    "density"        => "dn",
    "flags"          => "fl",
    "transformation" => "t",
  }

  # Settings
  site = context.registers[:site]
  site_url = site.config["url"] || ""
  site_baseurl = site.config["baseurl"] || ""
  if site.config["cloudinary"].nil?
    Jekyll.logger.abort_with("[Cloudinary]", "You must set your cloud_name in _config.yml")
  end
  settings = settings_defaults.merge(site.config["cloudinary"])
  if settings["cloud_name"] == ""
    Jekyll.logger.abort_with("[Cloudinary]", "You must set your cloud_name in _config.yml")
  end
  url = settings["origin_url"] || (site_url + site_baseurl)

  # Get Markdown converter
  markdown_converter = site.find_converter_instance(::Jekyll::Converters::Markdown)

  preset_user_defaults = {}
  if settings["presets"]
    if settings["presets"]["default"]
      preset_user_defaults = settings["presets"]["default"]
    end
  end

  preset = preset_defaults.merge(preset_user_defaults)

  # Render any liquid variables in tag arguments and unescape template code
  rendered_markup = Liquid::Template
    .parse(@markup)
    .render(context)
    .gsub(%r!\\\{\\\{|\\\{\\%!, '\{\{' => "{{", '\{\%' => "{%")

  # Extract tag segments
  markup =
    %r!^(?:(?<preset>[^\s.:\/]+)\s+)?(?<image_src>[^\s]+\.[a-zA-Z0-9]{3,4})\s*(?<html_attr>[\s\S]+)?$!
      .match(rendered_markup)

  unless markup
    Jekyll.logger.abort_with("[Cloudinary]", "Can't read this tag: #{@markup}")
  end

  image_src = markup[:image_src]

  # Dynamic image type
  type = "fetch"
  # TODO: URL2PNG requires signed URLs... need to investigate more
  # if /^url2png\:/.match(image_src)
  #   type = "url2png"
  #   image_src.gsub! "url2png:", ""
  # end

  if markup[:preset]
    if settings["presets"][markup[:preset]]
      preset = preset.merge(settings["presets"][markup[:preset]])
    elsif settings["verbose"]
      Jekyll.logger.warn(
        "[Cloudinary]",
        "'#{markup[:preset]}' preset for the Cloudinary plugin doesn't exist, \
        using the default one"
      )
    end
  end

  attributes = preset["attributes"]

  # Deep copy preset for single instance manipulation
  instance = Marshal.load(Marshal.dump(preset))

  # Process attributes
  html_attr = if markup[:html_attr]
                Hash[ *markup[:html_attr].scan(%r!(?<attr>[^\s="]+)(?:="(?<value>[^"]+)")?\s?!).flatten ]
              else
                {}
              end

  if instance["attr"]
    html_attr = instance.delete("attr").merge(html_attr)
  end

  # Classes from the tag should complete, not replace, the ones from the preset
  if html_attr["class"] && attributes["class"]
    html_attr["class"] << " #{attributes["class"]}"
  end
  html_attr = attributes.merge(html_attr)

  # Deal with the "caption" attribute as a true <figcaption>
  if html_attr["caption"]
    caption = markdown_converter.convert(html_attr["caption"])
    html_attr.delete("caption")
  end

  # alt and title attributes should go only to the <img> even when there is a caption
  img_attr = "".dup
  if html_attr["alt"]
    img_attr << " alt=\"#{html_attr["alt"]}\""
    html_attr.delete("alt")
  end
  if html_attr["title"]
    img_attr << " title=\"#{html_attr["title"]}\""
    html_attr.delete("title")
  end
  if html_attr["loading"]
    img_attr << " loading=\"#{html_attr["loading"]}\""
    html_attr.delete("loading")
  end

  attr_string = html_attr.map { |a, v| "#{a}=\"#{v}\"" }.join(" ")

  # Figure out the Cloudinary transformations
  transformations = []
  transformations_string = ""
  transformation_options.each do |key, shortcode|
    if preset[key]
      transformations << "#{shortcode}_#{preset[key]}"
    end
  end
  unless transformations.empty?
    transformations_string = transformations.compact.reject(&:empty?).join(",") + ","
  end

  # Build source image URL
  is_image_remote = %r!^https?!.match(image_src)
  if is_image_remote
    # It's remote
    image_dest_path = image_src
    image_dest_url = image_src
    natural_width, natural_height = FastImage.size(image_dest_url)
    if natural_width.nil?
      Jekyll.logger.warn("remote url doesn't exists " + image_dest_url)
      return "<img src=\"#{image_dest_url}\" />"
    end
    width_height = "width=\"#{natural_width}\" height=\"#{natural_height}\""
    fallback_url = "https://res.cloudinary.com/#{settings["cloud_name"]}/image/#{type}/#{transformations_string}w_#{preset["fallback_max_width"]}/#{image_dest_url}"
  else
    # It's a local image
    is_image_src_absolute = %r!^/.*$!.match(image_src)
    if is_image_src_absolute
      image_src_path = File.join(
        site.config["source"],
        image_src
      )
      image_dest_path = File.join(
        site.config["destination"],
        image_src
      )
      image_dest_url = File.join(
        url,
        image_src
      )
    else
      image_src_path = File.join(
        site.config["source"],
        File.dirname(context["page"]["path"].chomp("/#excerpt")),
        image_src
      )
      image_dest_path = File.join(
        site.config["destination"],
        File.dirname(context["page"]["url"]),
        image_src
      )
      image_dest_url = File.join(
        url,
        File.dirname(context["page"]["url"]),
        image_src
      )
    end
    if File.exist?(image_src_path)
      natural_width, natural_height = FastImage.size(image_src_path)
      width_height = "width=\"#{natural_width}\" height=\"#{natural_height}\""
      fallback_url = "https://res.cloudinary.com/#{settings["cloud_name"]}/image/#{type}/#{transformations_string}w_#{preset["fallback_max_width"]}/#{image_dest_url}"
    else
      natural_width = 100_000
      width_height = ""
      Jekyll.logger.warn(
        "[Cloudinary]",
        "Couldn't find this image to check its width: #{image_src_path}."
      )
      fallback_url = image_dest_url
    end
  end

  # Don't generate responsive image HTML and Cloudinary URLs for local development
  if settings["only_prod"] && ENV["JEKYLL_ENV"] != "production"
    return "<img src=\"#{image_dest_url}\" #{attr_string} #{img_attr} #{width_height} crossorigin=\"anonymous\" />"
  end

  srcset = []
  steps = preset["steps"].to_i
  min_width = preset["min_width"].to_i
  max_width = preset["max_width"].to_i
  step_width = (max_width - min_width) / (steps - 1)
  sizes = preset["sizes"]

  if natural_width < min_width
    if settings["verbose"]
      Jekyll.logger.warn(
        "[Cloudinary]",
        "Width of source image '#{File.basename(image_src)}' (#{natural_width}px) \
        in #{context["page"]["path"]} not enough for ANY srcset version"
      )
    end
    srcset << "https://res.cloudinary.com/#{settings["cloud_name"]}/image/#{type}/#{transformations_string}w_#{natural_width}/#{image_dest_url} #{natural_width}w"
  else
    missed_sizes = []
    (1..steps).each do |factor|
      width = min_width + (factor - 1) * step_width
      if width <= natural_width
        srcset << "https://res.cloudinary.com/#{settings["cloud_name"]}/image/#{type}/#{transformations_string}w_#{width}/#{image_dest_url} #{width}w"
      else
        missed_sizes.push(width)
      end
    end
    unless missed_sizes.empty?
      srcset << "https://res.cloudinary.com/#{settings["cloud_name"]}/image/#{type}/#{transformations_string}w_#{natural_width}/#{image_dest_url} #{natural_width}w"
      if settings["verbose"]
        Jekyll.logger.warn(
          "[Cloudinary]",
          "Width of source image '#{File.basename(image_src)}' (#{natural_width}px) \
          in #{context["page"]["path"]} not enough for #{missed_sizes.join("px, ")}px \
          version#{missed_sizes.length > 1 ? "s" : ""}"
        )
      end
    end
  end
  srcset_string = srcset.join(",\n")

  # preset['figure'] can be 'never', 'auto' or 'always'
  if (caption || preset["figure"] == "always") && preset["figure"] != "never"
    "\n<figure #{attr_string}>\n<img src=\"#{fallback_url}\" srcset=\"#{srcset_string}\" sizes=\"#{sizes}\" #{img_attr} #{width_height} />\n<figcaption>#{caption}</figcaption>\n</figure>\n"
  else
    "<img src=\"#{fallback_url}\" srcset=\"#{srcset_string}\" sizes=\"#{sizes}\" #{attr_string} #{img_attr} #{width_height} crossorigin=\"anonymous\" />"
  end
end