Class: Abqari::ImagePipeline

Inherits:
Object
  • Object
show all
Defined in:
lib/abqari/image_pipeline.rb,
lib/abqari/image_pipeline.rb

Overview


Backends — strategy pattern. Each implements thumbnail and dimensions; ImagePipeline picks one in detect_backend.

Defined Under Namespace

Classes: ShellBackend, VipsBackend

Constant Summary collapse

IMAGE_EXTENSIONS =

Raster sources the pipeline handles. SVG is excluded — vector, nothing to thumbnail.

%w[.jpg .jpeg .png .gif .webp .avif].freeze
DEFAULTS =
{
  'optimise'          => true,
  'processor'         => 'auto', # auto | vips-native | vips | imagemagick
  # AVIF first so browsers pick it ahead of WebP — AVIF is 20–50%
  # smaller at equal quality and has universal modern-browser
  # support as of 2026 (Safari 16.4+, Chrome/Edge 85+, Firefox
  # 113+). Older browsers fall through to WebP, then the original.
  'formats'           => %w[avif webp],
  'widths'            => [400, 800, 1200],
  # Per-format quality. AVIF's quality scale is perceptually
  # different from JPEG/WebP — 50 produces files smaller than WebP
  # at 80 with comparable quality. Applying one number across all
  # formats (the legacy behaviour) made AVIF files LARGER than
  # WebP, defeating the format's purpose.
  'quality'           => { 'avif' => 50, 'webp' => 80, 'original' => 85 },
  'sizes'             => '(min-width: 768px) 50vw, 100vw',
  'source_dir'        => 'app/assets/images',
  # Strip EXIF, GPS, embedded thumbnails, ICC profiles. Photographers
  # who need to preserve metadata (portfolio sites that surface
  # camera info) can flip this off.
  'preserve_metadata' => false
}.freeze
HASH_LENGTH =
10

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(site) ⇒ ImagePipeline

Returns a new instance of ImagePipeline.



77
78
79
80
81
82
83
84
85
86
87
# File 'lib/abqari/image_pipeline.rb', line 77

def initialize(site)
  @site = site
  @config = merged_config(site.config['images'])
  @backend = nil
  @variants_cache = {}
  @cache_mutex = Mutex.new
  @registry = {}
  @registry_mutex = Mutex.new
  @url_to_bundle_dir = nil
  @config['formats'] = prune_unsupported_formats(@config['formats'])
end

Class Method Details

.format_capabilitiesObject



298
299
300
# File 'lib/abqari/image_pipeline.rb', line 298

def format_capabilities
  @format_capabilities ||= {}
end

.warned_formatsObject



302
303
304
# File 'lib/abqari/image_pipeline.rb', line 302

def warned_formats
  @warned_formats ||= Set.new
end

Instance Method Details

#backendObject



93
94
95
96
97
# File 'lib/abqari/image_pipeline.rb', line 93

def backend
  @backend = detect_backend if @backend.nil? && !@backend_resolved
  @backend_resolved = true
  @backend
end

#enabled?Boolean

Returns:

  • (Boolean)


89
90
91
# File 'lib/abqari/image_pipeline.rb', line 89

def enabled?
  @config['optimise'] == true && backend
end

#picture_tag(source, alt: '', sizes: nil, widths: nil) ⇒ Object

picture_tag — emits a <picture> block for an asset image. The source argument is resolved under app/assets/images/ (the legacy ERB helper convention). For bundle images, prefer the automatic rewrite in post_process_html or the bundle_picture_tag helper.



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/abqari/image_pipeline.rb', line 124

def picture_tag(source, alt: '', sizes: nil, widths: nil)
  sizes ||= @config['sizes']
  requested_widths = (widths || @config['widths']).sort

  source_path = absolute_source(source)
  unless File.exist?(source_path)
    # Source doesn't exist locally — emit a literal <img> with the
    # source value as-is (allows authors to use full URLs).
    return %(<img src="#{escape_attr(source)}" alt="#{escape_attr(alt)}" loading="lazy" decoding="async">)
  end

  unless enabled?
    copy_passthrough(source_path, '/assets/images/')
    dims = dimension_attrs(source_path)
    url = "/assets/images/#{File.basename(source_path)}"
    return %(<img src="#{escape_attr(url)}" alt="#{escape_attr(alt)}"#{dims} loading="lazy" decoding="async">)
  end

  url_prefix = '/assets/images/'
  target_dir = File.join(@site.output_dir, 'assets', 'images')
  register_source("#{url_prefix}#{File.basename(source_path)}", source_path, target_dir, url_prefix)
  render_picture("#{url_prefix}#{File.basename(source_path)}", alt, sizes, requested_widths)
end

#picture_tag_for_url(url, alt:, sizes: nil, widths: nil, attrs: {}) ⇒ Object

Emit a <picture> block for a URL that's been registered with the pipeline (asset or bundle). Returns nil if the URL isn't registered — callers fall back to whatever they were going to do otherwise (a plain <img>, the legacy sibling-lookup, etc.).

Used by bundle_picture_tag in render_context to opt explicit layout-level uses into the pipeline.



155
156
157
158
159
160
161
162
# File 'lib/abqari/image_pipeline.rb', line 155

def picture_tag_for_url(url, alt:, sizes: nil, widths: nil, attrs: {})
  return nil unless enabled?
  return nil unless source_path_for(canonical_url(url))

  sizes ||= @config['sizes']
  widths = (widths || @config['widths']).sort
  render_picture(canonical_url(url), alt, sizes, widths, extra_attrs: attrs)
end

#post_process_html(html, page: nil) ⇒ Object

Rewrite <img> tags whose src we know how to optimise. Asset URLs and bundle URLs are both routed through the registry — any <img src=...> that maps to a registered source gets replaced with a <picture> element. Unknown images pass through unchanged.

page: is accepted for symmetry with other post-processors but the registry lookup is URL-based (we already have a complete map by Site#build time) so the page itself isn't needed here.



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/abqari/image_pipeline.rb', line 173

def post_process_html(html, page: nil)
  return html unless enabled?

  html.gsub(/<img\s+([^>]*?)>/m) do |match|
    attrs = ::Regexp.last_match(1)
    src = attrs[/src="([^"]+)"/, 1]
    next match unless src

    registered_url = canonical_url(src)
    next match unless source_path_for(registered_url)

    alt = attrs[/alt="([^"]*)"/, 1] || ''
    render_picture(registered_url, alt, @config['sizes'], @config['widths'].sort)
  end
end

#processObject

Phase entry point — called from Site#build BEFORE render. Copies app/assets/images/ originals through (always — needed for the passthrough case), then if optimisation is on, walks every known image source and registers it in the URL → variants map so post_process_html can rewrite <img> tags during render.



104
105
106
107
108
109
110
# File 'lib/abqari/image_pipeline.rb', line 104

def process
  copy_originals
  return unless enabled?

  build_url_to_bundle_dir_map
  register_known_sources
end

#source_path_for(url) ⇒ Object

Resolve a URL to its on-disk source path, or nil if we don't recognise it. Used by post-processing to figure out which <img src=...> tags to rewrite.



115
116
117
# File 'lib/abqari/image_pipeline.rb', line 115

def source_path_for(url)
  @registry_mutex.synchronize { @registry[url]&.dig(:source_path) }
end