better_image_tag 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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