better_image_tag 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterImageTag
4
+ module Commands
5
+ class ConvertJpgToAvif
6
+ def self.call
7
+ new.call
8
+ end
9
+
10
+ def initialize
11
+ @jpgs_converted = 0
12
+ end
13
+
14
+ def call
15
+ ensure_avif_present!
16
+
17
+ jpg_assets.each do |jpg|
18
+ avif = jpg.gsub(/\.jpe?g\z/i, '.avif')
19
+ next if File.exist? avif
20
+
21
+ @jpgs_converted += 1 if system("avif -q 32 -e #{jpg} -o #{avif}")
22
+ end
23
+
24
+ puts "#{@jpgs_converted} jpgs converted to avif."
25
+ end
26
+
27
+ private
28
+
29
+ def ensure_avif_present!
30
+ return if avif_exists?
31
+
32
+ raise(
33
+ BetterImageTag::Errors::AvifNotFound,
34
+ "'avif' not found. Please install go-avif."
35
+ )
36
+ end
37
+
38
+ def avif_exists?
39
+ `which avif`
40
+ $CHILD_STATUS.success?
41
+ end
42
+
43
+ def jpg_assets
44
+ Dir.glob "#{asset_path}/**/*.{jpg,jpeg}"
45
+ end
46
+
47
+ def asset_path
48
+ BetterImageTag.configuration.images_path
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterImageTag
4
+ module Commands
5
+ class ConvertJpgToWebp
6
+ def self.call
7
+ new.call
8
+ end
9
+
10
+ def initialize
11
+ @jpgs_converted = 0
12
+ end
13
+
14
+ def call
15
+ ensure_convert_present!
16
+
17
+ jpg_assets.each do |jpg|
18
+ webp = jpg.gsub(/\.jpe?g\z/i, '.webp')
19
+ next if File.exist? webp
20
+
21
+ @jpgs_converted += 1 if system("convert #{jpg} #{webp}")
22
+ end
23
+
24
+ puts "#{@jpgs_converted} jpgs converted to webp."
25
+ end
26
+
27
+ private
28
+
29
+ def ensure_convert_present!
30
+ return if convert_exists?
31
+
32
+ raise(
33
+ BetterImageTag::Errors::ConvertNotFound,
34
+ "'convert' not found. Please install ImageMagick."
35
+ )
36
+ end
37
+
38
+ def convert_exists?
39
+ `which convert`
40
+ $CHILD_STATUS.success?
41
+ end
42
+
43
+ def jpg_assets
44
+ Dir.glob "#{asset_path}/**/*.{jpg,jpeg}"
45
+ end
46
+
47
+ def asset_path
48
+ BetterImageTag.configuration.images_path
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterImageTag
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterImageTag
4
+ module Errors
5
+ class Error < StandardError; end
6
+
7
+ class MissingAltTag < Error; end
8
+ class EarlyLazyLoad < Error; end
9
+ class ConvertNotFound < Error; end
10
+ class AvifNotFound < Error; end
11
+ class FileNotFound < Error; end
12
+ end
13
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fastimage'
4
+
5
+ module BetterImageTag
6
+ class ImageTag
7
+ TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
8
+
9
+ attr_reader :view_context, :options, :images, :tablet_options, :desktop_options
10
+ attr_accessor :image
11
+
12
+ def initialize(view_context, image, options = {})
13
+ @view_context = view_context
14
+ @image = with_protocol(image)
15
+ @images = []
16
+ @options = options.symbolize_keys
17
+
18
+ avif(@options[:avif]) if @options[:avif]
19
+ webp(@options[:webp]) if @options[:webp]
20
+ enforce_requirements
21
+ end
22
+
23
+ def with_size
24
+ return self if options[:width].present? || options[:height].present?
25
+ return self unless BetterImageTag.configuration.sizing_enabled
26
+
27
+ dims = cache("image_tag:with_size:#{image}") { FastImage.size(asset) }
28
+ options[:width] = dims&.first
29
+ options[:height] = dims&.last
30
+
31
+ self
32
+ end
33
+
34
+ # rubocop:disable Metrics/AbcSize
35
+ def lazy_load(enabled: true)
36
+ return self unless enabled
37
+
38
+ options[:class] = Array(options.fetch(:class, [])).join(' ')
39
+ options[:class] = "#{options[:class]} lazyload".strip
40
+ options[:data] = options[:data]
41
+ .to_h
42
+ .merge(src: view_context.image_path(image))
43
+
44
+ @image = TRANSPARENT_GIF
45
+
46
+ self
47
+ end
48
+ # rubocop:enable Metrics/AbcSize
49
+
50
+ def webp(url = nil)
51
+ lazy_load_last!
52
+ @images << (url || image.gsub(/\.[a-z]{2,}*\z/, '.webp'))
53
+ self
54
+ end
55
+
56
+ def avif(url = nil)
57
+ lazy_load_last!
58
+ @images << (url || image.gsub(/\.[a-z]{2,}*\z/, '.avif'))
59
+ self
60
+ end
61
+
62
+ def inline
63
+ @image = InlineData.new(@image).inline_data
64
+
65
+ self
66
+ end
67
+
68
+ def to_s
69
+ (svg_string || image_tag_string || picture_tag_string).html_safe
70
+ end
71
+
72
+ def picture_tag
73
+ result = view_context.image_tag(
74
+ image,
75
+ options.except(:webp, :avif).merge(use_super: true)
76
+ )
77
+ PictureTag.new(self, result)
78
+ end
79
+
80
+ def tablet_up(*tablet_options)
81
+ @tablet_options = tablet_options
82
+ self
83
+ end
84
+
85
+ def desktop_up(*desktop_options)
86
+ @desktop_options = desktop_options
87
+ self
88
+ end
89
+
90
+ private
91
+
92
+ def svg_string
93
+ return unless svg?
94
+
95
+ SvgTag.new(self).to_s
96
+ end
97
+
98
+ def image_tag_string
99
+ return if images.any? || tablet_options&.any? || desktop_options&.any?
100
+
101
+ view_context.image_tag(
102
+ image,
103
+ options.except(:webp, :avif).merge(super_options)
104
+ )
105
+ end
106
+
107
+ def picture_tag_string
108
+ picture_tag.to_s
109
+ end
110
+
111
+ def svg?
112
+ MimeMagic.by_magic(@image)&.type == 'image/svg+xml'
113
+ end
114
+
115
+ def super_options
116
+ if images.any?
117
+ { use_picture: true }
118
+ else
119
+ { use_super: true }
120
+ end
121
+ end
122
+
123
+ def lazy_load_last!
124
+ return unless image.match?(/^data:/)
125
+
126
+ raise EarlyLazyLoad, 'Run lazy_load as the last method in chain'
127
+ end
128
+
129
+ def with_protocol(image)
130
+ image.match?(%r{^//}) ? "https:#{image}" : image
131
+ end
132
+
133
+ def asset
134
+ @_asset ||= begin
135
+ if image.match?(%r{https?://}) || !Object.const_defined?(:Rails)
136
+ image
137
+ elsif not_compiled?
138
+ Rails.application.assets[image].filename
139
+ else
140
+ file = Rails.application.assets_manifest.assets[image]
141
+
142
+ if file.nil?
143
+ raise(
144
+ BetterImageTag::Errors::FileNotFound,
145
+ "Not found in asset manifest: #{image}"
146
+ )
147
+ end
148
+
149
+ File.join(Rails.application.assets_manifest.directory, file)
150
+ end
151
+ end
152
+ end
153
+
154
+ def not_compiled?
155
+ !!Rails.application.assets
156
+ end
157
+
158
+ def enforce_requirements
159
+ if BetterImageTag.configuration.require_alt_tags && options[:alt].blank?
160
+ raise Errors::MissingAltTag, "#{image} is missing an alt tag"
161
+ end
162
+ end
163
+
164
+ def cache(tag, &block)
165
+ return unless block
166
+
167
+ return block.call unless BetterImageTag.configuration.cache_sizing_enabled
168
+
169
+ Rails.cache.fetch tag, &block
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mimemagic'
4
+ require 'base64'
5
+ require 'open-uri'
6
+
7
+ module BetterImageTag
8
+ class InlineData
9
+ HTTP_ERRORS = [
10
+ EOFError,
11
+ Errno::ECONNRESET,
12
+ Errno::EINVAL,
13
+ Net::HTTPBadResponse,
14
+ Net::HTTPHeaderSyntaxError,
15
+ Net::ProtocolError,
16
+ Timeout::Error,
17
+ OpenSSL::SSL::SSLError,
18
+ OpenURI::HTTPError
19
+ ].freeze
20
+
21
+ CACHE_PREFIX = 'inline_data'
22
+
23
+ def self.inline_data(*args)
24
+ new(*args).inline_data
25
+ end
26
+
27
+ attr_reader :image
28
+
29
+ def initialize(image, local_file: false)
30
+ @image = image
31
+ @local_file = local_file
32
+ end
33
+
34
+ def inline_data
35
+ return image unless BetterImageTag.configuration.inlining_enabled
36
+
37
+ cache "#{CACHE_PREFIX}:#{image}" do
38
+ svg? ? contents : "data:#{content_type};base64,#{base64_contents}"
39
+ end
40
+ rescue *HTTP_ERRORS
41
+ image
42
+ end
43
+
44
+ private
45
+
46
+ def cache(tag, &block)
47
+ return unless block
48
+
49
+ unless BetterImageTag.configuration.cache_inlining_enabled
50
+ return block.call
51
+ end
52
+
53
+ Rails.cache.fetch tag, &block
54
+ end
55
+
56
+ def svg?
57
+ content_type == "image/svg+xml"
58
+ end
59
+
60
+ def content_type
61
+ MimeMagic.by_magic(contents).type
62
+ end
63
+
64
+ def base64_contents
65
+ Base64.strict_encode64 contents
66
+ end
67
+
68
+ # rubocop:disable Security/Open
69
+ def contents
70
+ @_contents ||= begin
71
+ if image.match?(%r{https?://})
72
+ URI.open(image).read
73
+ elsif local_file?
74
+ File.read(image)
75
+ elsif not_compiled?
76
+ Rails.application.assets[image].to_s
77
+ else
78
+ file = Rails.application.assets_manifest.assets[image]
79
+
80
+ if file.nil?
81
+ raise(
82
+ BetterImageTag::Errors::FileNotFound,
83
+ "Not found in asset manifest: #{image}"
84
+ )
85
+ end
86
+
87
+ path = File.join(Rails.application.assets_manifest.directory, file)
88
+ File.read(path)
89
+ end
90
+ end
91
+ end
92
+ # rubocop:enable Security/Open
93
+
94
+ def not_compiled?
95
+ !!Rails.application.assets
96
+ end
97
+
98
+ def local_file?
99
+ @local_file
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module BetterImageTag
6
+ class PictureTag
7
+ attr_reader :image_tag, :default_image_tag, :sources, :srcset
8
+
9
+ extend Forwardable
10
+ def_delegators :image_tag, :image, :images, :view_context
11
+ def_delegators :view_context, :image_path
12
+ def_delegators :config, :tablet_breakpoint, :desktop_breakpoint
13
+
14
+ def initialize(image_tag, default_image_tag)
15
+ @image_tag = image_tag
16
+ @default_image_tag = default_image_tag
17
+ @sources = []
18
+ end
19
+
20
+ def to_s
21
+ output(:lazily) || output(:normally)
22
+ end
23
+
24
+ private
25
+
26
+ def config
27
+ BetterImageTag.configuration
28
+ end
29
+
30
+ def output(loading_style)
31
+ return if loading_style == :lazily && image != ImageTag::TRANSPARENT_GIF
32
+
33
+ @srcset = loading_style == :lazily ? 'data-srcset' : 'srcset'
34
+ css_class = css_class? ? %( class="#{css_classes}") : ''
35
+
36
+ populate_responsive_and_format_sources
37
+ populate_responsive_sources
38
+ populate_format_sources
39
+
40
+ <<~EOPICTURE
41
+ <picture#{css_class}>
42
+ <!--[if IE 9]><video style="display: none;"><![endif]-->
43
+ #{sources.join("\n ")}
44
+ <!--[if IE 9]></video><![endif]-->
45
+ #{default_image_tag}
46
+ </picture>
47
+ EOPICTURE
48
+ end
49
+
50
+ def css_class?
51
+ image_tag.options[:class].present?
52
+ end
53
+
54
+ def css_classes
55
+ image_tag.options[:class].split(' ').map do |css_class|
56
+ css_class == 'lazyload' ? 'lazyload' : "#{css_class}--picture"
57
+ end.join(" ")
58
+ end
59
+
60
+ def populate_responsive_and_format_sources
61
+ if image_tag.tablet_options&.second
62
+ if image_tag.tablet_options.second[:avif]
63
+ @sources << %(<source media="(min-width: #{tablet_breakpoint})" type="image/avif" #{srcset}="#{image_path image_tag.tablet_options.second[:avif]}">)
64
+ end
65
+
66
+ if image_tag.tablet_options.second[:webp]
67
+ @sources << %(<source media="(min-width: #{tablet_breakpoint})" type="image/webp" #{srcset}="#{image_path image_tag.tablet_options.second[:webp]}">)
68
+ end
69
+ end
70
+
71
+ if image_tag.desktop_options&.second
72
+ if image_tag.desktop_options.second[:avif]
73
+ @sources << %(<source media="(min-width: #{desktop_breakpoint})" type="image/avif" #{srcset}="#{image_path image_tag.desktop_options.second[:avif]}">)
74
+ end
75
+
76
+ if image_tag.desktop_options.second[:webp]
77
+ @sources << %(<source media="(min-width: #{desktop_breakpoint})" type="image/webp" #{srcset}="#{image_path image_tag.desktop_options.second[:webp]}">)
78
+ end
79
+ end
80
+ end
81
+
82
+ def populate_format_sources
83
+ return if images.empty?
84
+
85
+ @sources += images.map do |image|
86
+ type = image.match?(/webp$/) ? 'webp' : 'avif'
87
+ %(<source type="image/#{type}" #{srcset}="#{image_path image}">)
88
+ end
89
+ end
90
+
91
+ def populate_responsive_sources
92
+ return if image_tag.tablet_options.blank? && image_tag.desktop_options.blank?
93
+
94
+ if image_tag.tablet_options
95
+ @sources << %(<source media="(min-width: #{tablet_breakpoint})" #{srcset}="#{image_path image_tag.tablet_options.first}">)
96
+ end
97
+
98
+ if image_tag.desktop_options
99
+ @sources << %(<source media="(min-width: #{desktop_breakpoint})" #{srcset}="#{image_path image_tag.desktop_options.first}">)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'better_image_tag'
4
+ require 'rails'
5
+
6
+ module BetterImageTag
7
+ class Railtie < Rails::Railtie
8
+ railtie_name :better_image_tag
9
+
10
+ rake_tasks do
11
+ path = File.expand_path(__dir__)
12
+ Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ module BetterImageTag
2
+ module SpecHelpers
3
+ def disable_better_image_tag_sizing!
4
+ BetterImageTag.configuration.sizing_enabled = false
5
+ end
6
+
7
+ def disable_better_image_tag_inlining!
8
+ BetterImageTag.configuration.inlining_enabled = false
9
+ end
10
+ end
11
+
12
+ module ViewSpecHelpers
13
+ def better_image_tag(options = {})
14
+ view.send(:extend, BetterImageTag::ImageTaggable)
15
+
16
+ without_partial_double_verification do
17
+ allow(view.class).
18
+ to receive(:better_image_tag_options).
19
+ and_return(options)
20
+ allow(view).to receive(:view_context).and_return(view)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterImageTag
4
+ class SvgTag
5
+ attr_reader :image_tag
6
+
7
+ def initialize(image_tag)
8
+ @image_tag = image_tag
9
+ end
10
+
11
+ def to_s
12
+ image_tag.image.gsub!(/^\<svg /, %(<svg height="#{height}" )) if height
13
+ image_tag.image.gsub!(/^\<svg /, %(<svg width="#{width}" )) if width
14
+
15
+ if css_class
16
+ image_tag.image.gsub!(/^\<svg /, %(<svg class="#{css_class}" ))
17
+ end
18
+
19
+ image_tag.image
20
+ end
21
+
22
+ private
23
+
24
+ def width
25
+ image_tag.options[:width]
26
+ end
27
+
28
+ def height
29
+ image_tag.options[:height]
30
+ end
31
+
32
+ def css_class
33
+ image_tag.options[:class]
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'better_image_tag/commands/clear_inline_cache'
4
+
5
+ namespace :better_image_tag do
6
+ desc 'Clear cached inline data'
7
+ task :clear_inline_cache do
8
+ BetterImageTag::Commands::ClearInlineCache.call
9
+ end
10
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'better_image_tag/commands/convert_jpg_to_webp'
4
+ require 'better_image_tag/commands/convert_jpg_to_avif'
5
+
6
+ namespace :better_image_tag do
7
+ desc 'Convert jpgs to webp'
8
+ task :convert_jpgs_to_webp do
9
+ BetterImageTag::Commands::ConvertJpgToWebp.call
10
+ end
11
+
12
+ desc 'Convert jpgs to avif'
13
+ task :convert_jpgs_to_avif do
14
+ BetterImageTag::Commands::ConvertJpgToAvif.call
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ module BetterImageTag
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'better_image_tag/version'
4
+ require 'better_image_tag/errors'
5
+ require 'better_image_tag/picture_tag'
6
+ require 'better_image_tag/svg_tag'
7
+ require 'better_image_tag/image_tag'
8
+ require 'better_image_tag/base_image_tag'
9
+ require 'better_image_tag/inline_data'
10
+ require_relative '../app/controllers/concerns/better_image_tag/image_taggable'
11
+ require 'better_image_tag/railtie' if Object.const_defined?(:Rails)
12
+
13
+ module BetterImageTag
14
+ class << self
15
+ attr_writer :configuration
16
+ end
17
+
18
+ def self.configuration
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ def self.configure
23
+ self.configuration ||= Configuration.new
24
+ yield(configuration)
25
+ end
26
+
27
+ class Configuration
28
+ attr_accessor(
29
+ :cache_inlining_enabled,
30
+ :cache_sizing_enabled,
31
+ :inlining_enabled,
32
+ :require_alt_tags,
33
+ :sizing_enabled,
34
+ :images_path,
35
+ :tablet_breakpoint,
36
+ :desktop_breakpoint
37
+ )
38
+
39
+ def initialize
40
+ @require_alt_tags = false
41
+ @cache_sizing_enabled = false
42
+ @cache_inlining_enabled = false
43
+ @inlining_enabled = true
44
+ @sizing_enabled = true
45
+ @tablet_breakpoint = '768px'
46
+ @desktop_breakpoint = '1280px'
47
+ @images_path = "#{rails_root}/app/assets/images"
48
+ end
49
+
50
+ private
51
+
52
+ def rails_root
53
+ Object.const_defined?(:Rails) ? Rails.root : '.'
54
+ end
55
+ end
56
+ end