esvg 4.1.6 → 4.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.
data/lib/esvg/svgs.rb ADDED
@@ -0,0 +1,320 @@
1
+ require 'yaml'
2
+ require 'json'
3
+ require 'zlib'
4
+ require 'digest'
5
+ require 'esvg/symbol'
6
+
7
+ module Esvg
8
+ class Svgs
9
+ include Esvg::Utils
10
+
11
+ attr_reader :symbols
12
+
13
+ CONFIG = {
14
+ filename: 'svgs',
15
+ class: 'svg-symbol',
16
+ prefix: 'svg',
17
+ core: true,
18
+ optimize: false,
19
+ gzip: false,
20
+ fingerprint: true,
21
+ throttle_read: 4,
22
+ flatten: [],
23
+ alias: {}
24
+ }
25
+
26
+ CONFIG_RAILS = {
27
+ source: "app/assets/svgs",
28
+ assets: "app/assets/javascripts",
29
+ build: "public/javascripts",
30
+ temp: "tmp"
31
+ }
32
+
33
+ def initialize(options={})
34
+ config(options)
35
+ @symbols = []
36
+ @svgs = []
37
+ @last_read = nil
38
+ read_cache
39
+
40
+ load_files
41
+ end
42
+
43
+ def config(options={})
44
+ @config ||= begin
45
+ paths = [options[:config_file], 'config/esvg.yml', 'esvg.yml'].compact
46
+
47
+ config = CONFIG.dup
48
+
49
+ if Esvg.rails? || options[:rails]
50
+ config.merge!(CONFIG_RAILS)
51
+ end
52
+
53
+ if path = paths.select{ |p| File.exist?(p)}.first
54
+ config.merge!(symbolize_keys(YAML.load(File.read(path) || {})))
55
+ end
56
+
57
+ config.merge!(options)
58
+
59
+ config[:filename] = File.basename(config[:filename], '.*')
60
+
61
+ config[:pwd] = File.expand_path Dir.pwd
62
+ config[:source] = File.expand_path config[:source] || config[:pwd]
63
+ config[:build] = File.expand_path config[:build] || config[:pwd]
64
+ config[:assets] = File.expand_path config[:assets] || config[:pwd]
65
+
66
+ config[:temp] = config[:pwd] if config[:temp].nil?
67
+ config[:temp] = File.expand_path File.join(config[:temp], '.esvg-cache')
68
+
69
+ config[:aliases] = load_aliases(config[:alias])
70
+ config[:flatten] = [config[:flatten]].flatten.map { |dir| File.join(dir, '/') }.join('|')
71
+
72
+ config
73
+ end
74
+ end
75
+
76
+ def load_files
77
+ if !@last_read.nil? && (Time.now.to_i - @last_read) < config[:throttle_read]
78
+ return
79
+ end
80
+
81
+ files = Dir[File.join(config[:source], '**/*.svg')].uniq.sort
82
+
83
+ if files.empty? && config[:print]
84
+ puts "No svgs found at #{config[:source]}"
85
+ return
86
+ end
87
+
88
+ # Remove deleted files
89
+ @symbols.reject(&:read).each { |s| @symbols.delete(s) }
90
+
91
+ files.each do |path|
92
+ unless @symbols.find { |s| s.path == path }
93
+ @symbols << Symbol.new(path, config)
94
+ end
95
+ end
96
+
97
+ @svgs.clear
98
+
99
+ sort(@symbols.group_by(&:group)).each do |name, symbols|
100
+ @svgs << Svg.new(name, symbols, config)
101
+ end
102
+
103
+ @last_read = Time.now.to_i
104
+
105
+ puts "Read #{@symbols.size} files from #{config[:source]}" if config[:print]
106
+
107
+ end
108
+
109
+ def build
110
+
111
+ paths = []
112
+
113
+ if config[:core]
114
+ path = File.join config[:assets], "_esvg.js"
115
+ write_file path, js_core
116
+ paths << path
117
+ end
118
+
119
+ @svgs.each do |file|
120
+ write_file file.path, js(file.embed)
121
+
122
+ puts "Writing #{file.path}" if config[:print]
123
+ paths << file.path
124
+
125
+ if !file.asset && config[:gzip] && gz = compress(file[:path])
126
+ puts "Writing #{gz}" if config[:print]
127
+ paths << gz
128
+ end
129
+ end
130
+
131
+ write_cache
132
+ paths
133
+ end
134
+
135
+ def write_cache
136
+ write_tmp '.symbols', @symbols.map(&:data).to_yaml
137
+ end
138
+
139
+ def read_cache
140
+ (YAML.load(read_tmp '.symbols') || []).each do |s|
141
+ @symbols << Symbol.new(s[:path], config)
142
+ end
143
+ end
144
+
145
+ # Embed only build scripts
146
+ def embed_script(names=nil)
147
+ embeds = buildable_svgs(names).map(&:embed)
148
+ if !embeds.empty?
149
+ "<script>#{js(embeds.join("\n"))}</script>"
150
+ end
151
+ end
152
+
153
+ def build_paths(names=nil)
154
+ buildable_svgs(names).map{ |f| File.basename(f.path) }
155
+ end
156
+
157
+ def find_symbol(name, fallback=nil)
158
+ name = get_alias dasherize(name)
159
+
160
+ if svg = @symbols.find { |s| s.name == name }
161
+ svg
162
+ elsif fallback
163
+ find_symbol(fallback)
164
+ end
165
+ end
166
+
167
+ def find_svgs(names=nil)
168
+ return @svgs if names.nil? || names.empty?
169
+
170
+ @svgs.select { |svg| svg.named?(names) }
171
+ end
172
+
173
+ def buildable_svgs(names=nil)
174
+ find_svgs(names).reject(&:asset)
175
+ end
176
+
177
+ private
178
+
179
+ def js(embed)
180
+ %Q{(function(){
181
+
182
+ function embed() {
183
+ #{embed}
184
+ }
185
+
186
+ // If DOM is already ready, embed SVGs
187
+ if (document.readyState == 'interactive') { embed() }
188
+
189
+ // Handle Turbolinks page change events
190
+ if ( window.Turbolinks ) {
191
+ document.addEventListener("turbolinks:load", function(event) { embed() })
192
+ }
193
+
194
+ // Handle standard DOM ready events
195
+ document.addEventListener("DOMContentLoaded", function(event) { embed() })
196
+ })()}
197
+ end
198
+
199
+ def js_core
200
+ %Q{(function(){
201
+ var names
202
+
203
+ function attr( source, name ){
204
+ if (typeof source == 'object')
205
+ return name+'="'+source.getAttribute(name)+'" '
206
+ else
207
+ return name+'="'+source+'" ' }
208
+
209
+ function dasherize( input ) {
210
+ return input.replace(/[\\W,_]/g, '-').replace(/-{2,}/g, '-')
211
+ }
212
+
213
+ function svgName( name ) {
214
+ return "#{config[:prefix]}-"+dasherize( name )
215
+ }
216
+
217
+ function use( name, options ) {
218
+ options = options || {}
219
+ var id = dasherize( name )
220
+ var symbol = svgs()[id]
221
+
222
+ if ( symbol ) {
223
+ var parent = symbol.parentElement
224
+ var prefix = parent.dataset.prefix
225
+ var base = parent.dataset.symbolClass
226
+
227
+ var svg = document.createRange().createContextualFragment( '<svg><use xlink:href="#'+id+'"/></svg>' ).firstChild;
228
+ svg.setAttribute( 'class', base + ' ' + prefix + '-' + id + ' ' + ( options.class || '' ).trim() )
229
+ svg.setAttribute( 'viewBox', symbol.getAttribute( 'viewBox' ) )
230
+
231
+ if ( !( options.width || options.height || options.scale ) ) {
232
+
233
+ svg.setAttribute('width', symbol.getAttribute('width'))
234
+ svg.setAttribute('height', symbol.getAttribute('height'))
235
+
236
+ } else {
237
+
238
+ if ( options.width ) svg.setAttribute( 'width', options.width )
239
+ if ( options.height ) svg.setAttribute( 'height', options.height )
240
+ }
241
+
242
+ return svg
243
+ } else {
244
+ console.error('Cannot find "'+name+'" svg symbol. Ensure that svg scripts are loaded')
245
+ }
246
+ }
247
+
248
+ function svgs(){
249
+ if ( !names ) {
250
+ names = {}
251
+ for( var symbol of document.querySelectorAll( 'svg[id^=esvg] symbol' ) ) {
252
+ names[symbol.dataset.name] = symbol
253
+ }
254
+ }
255
+ return names
256
+ }
257
+
258
+ var esvg = {
259
+ svgs: svgs,
260
+ use: use
261
+ }
262
+
263
+ // Handle Turbolinks page change events
264
+ if ( window.Turbolinks ) {
265
+ document.addEventListener( "turbolinks:load", function( event ) { names = null; esvg.svgs() })
266
+ }
267
+
268
+ if( typeof( module ) != 'undefined' ) { module.exports = esvg }
269
+ else window.esvg = esvg
270
+
271
+ })()}
272
+ end
273
+
274
+ def get_alias(name)
275
+ config[:aliases][dasherize(name).to_sym] || name
276
+ end
277
+
278
+ # Load aliases from configuration.
279
+ # returns a hash of aliasees mapped to a name.
280
+ # Converts configuration YAML:
281
+ # alias:
282
+ # foo: bar
283
+ # baz: zip, zop
284
+ # To output:
285
+ # { :bar => "foo", :zip => "baz", :zop => "baz" }
286
+ #
287
+ def load_aliases(aliases)
288
+ a = {}
289
+ aliases.each do |name,alternates|
290
+ alternates.split(',').each do |val|
291
+ a[dasherize(val.strip).to_sym] = dasherize(name.to_s)
292
+ end
293
+ end
294
+ a
295
+ end
296
+
297
+ def write_tmp(name, content)
298
+ path = File.join(config[:temp], name)
299
+ FileUtils.mkdir_p(File.dirname(path))
300
+ write_file path, content
301
+ path
302
+ end
303
+
304
+ def read_tmp(name)
305
+ path = File.join(config[:temp], name)
306
+ if File.exist? path
307
+ File.read path
308
+ else
309
+ ''
310
+ end
311
+ end
312
+
313
+ def write_file(path, contents)
314
+ FileUtils.mkdir_p(File.expand_path(File.dirname(path)))
315
+ File.open(path, 'w') do |io|
316
+ io.write(contents)
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,249 @@
1
+ require 'open3'
2
+
3
+ module Esvg
4
+ class Symbol
5
+ attr_reader :name, :id, :path, :content, :optimized, :size, :group, :mtime
6
+
7
+ include Esvg::Utils
8
+
9
+ def initialize(path, config={})
10
+ @config = config
11
+ @path = path
12
+ load_data
13
+ read
14
+ end
15
+
16
+ def read
17
+ return if !File.exist?(@path)
18
+
19
+ time = last_modified
20
+ if @mtime != time
21
+ @mtime = time
22
+ @content = pre_optimize File.read(@path)
23
+ @size = dimensions
24
+ @optimized = nil
25
+ end
26
+ @group = dir_key
27
+ @name = file_name
28
+ @id = file_id file_key
29
+
30
+ self
31
+ end
32
+
33
+ def data
34
+ {
35
+ path: @path,
36
+ id: @id,
37
+ name: @name,
38
+ group: @group,
39
+ last_modified: @mtime,
40
+ size: @size,
41
+ content: @content,
42
+ optimized: @optimized,
43
+ optimized_at: @optimized_at
44
+ }
45
+ end
46
+
47
+ def attr
48
+ { id: @id, 'data-name': @name }.merge @size
49
+ end
50
+
51
+ def use(options={})
52
+ if options[:color]
53
+ options[:style] ||= ''
54
+ options[:style] += "color:#{options[:color]};#{options[:style]}"
55
+ end
56
+
57
+ use_attr = {
58
+ class: [@config[:class], @config[:prefix]+"-"+@name, options[:class]].compact.join(' '),
59
+ viewBox: @size[:viewBox],
60
+ style: options[:style],
61
+ fill: options[:fill]
62
+ }
63
+
64
+ # If user doesn't pass a size or set scale: true
65
+ if !(options[:width] || options[:height] || options[:scale])
66
+
67
+ # default to svg dimensions
68
+ use_attr[:width] = @size[:width]
69
+ use_attr[:height] = @size[:height]
70
+ else
71
+
72
+ # Add sizes (nil options will be stripped)
73
+ use_attr[:width] = options[:width]
74
+ use_attr[:height] = options[:height]
75
+ end
76
+
77
+ %Q{<svg #{attributes(use_attr)}>#{use_tag}#{title(options)}#{desc(options)}#{options[:content]||''}</svg>}
78
+ end
79
+
80
+ def use_tag(options={})
81
+ options["xlink:href"] = "##{@id}"
82
+ %Q{<use #{attributes(options)}/>}
83
+ end
84
+
85
+ def title(options)
86
+ if options[:title]
87
+ "<title>#{options[:title]}</title>"
88
+ else
89
+ ''
90
+ end
91
+ end
92
+
93
+ def desc(options)
94
+ if options[:desc]
95
+ "<desc>#{options[:desc]}</desc>"
96
+ else
97
+ ''
98
+ end
99
+ end
100
+
101
+ def optimize
102
+ # Only optimize again if the file has changed
103
+ return @optimized if @optimized_at && @optimized_at > @mtime
104
+
105
+ @optimized = @content
106
+ sub_def_ids
107
+
108
+ if @config[:optimize] && Esvg.node_module('svgo')
109
+ response = Open3.capture3(%Q{#{Esvg.node_module('svgo')} --disable=removeUselessDefs -s '#{@optimized}' -o -})
110
+ @optimized = response[0] if response[2].success?
111
+ end
112
+
113
+ post_optimize
114
+ @optimized_at = Time.now.to_i
115
+
116
+ @optimized
117
+ end
118
+
119
+ private
120
+
121
+ def load_data
122
+ if @config[:cache]
123
+ @config.delete(:cache).each do |name, value|
124
+ set_instance name.to_s, value
125
+ end
126
+ end
127
+ end
128
+
129
+ def last_modified
130
+ File.mtime(@path).to_i
131
+ end
132
+
133
+ def file_id(name)
134
+ dasherize "#{@config[:prefix]}-#{name}"
135
+ end
136
+
137
+ def local_path
138
+ @local_path ||= sub_path(@config[:source], @path)
139
+ end
140
+
141
+ def file_name
142
+ dasherize flatten_path.sub('.svg','')
143
+ end
144
+
145
+ def file_key
146
+ dasherize local_path.sub('.svg','')
147
+ end
148
+
149
+ def dir_key
150
+ dir = File.dirname(flatten_path)
151
+
152
+ # Flattened paths which should be treated as assets will use '_' as their dir key
153
+ # - flatten: _foo - _foo/icon.svg will have a dirkey of _
154
+ # - filename: _icons - treats all root or flattened files as assets
155
+ if dir == '.' && ( local_path.start_with?('_') || @config[:filename].start_with?('_') )
156
+ '_'
157
+ else
158
+ dir
159
+ end
160
+ end
161
+
162
+ def flatten_path
163
+ @flattened_path ||= local_path.sub(Regexp.new(@config[:flatten]), '')
164
+ end
165
+
166
+ def name_key(key)
167
+ if key == '_' # Root level asset file
168
+ "_#{@config[:filename]}".sub(/_+/, '_')
169
+ elsif key == '.' # Root level build file
170
+ @config[:filename]
171
+ else
172
+ "#{key}"
173
+ end
174
+ end
175
+
176
+ def dimensions
177
+ if viewbox = @content.scan(/<svg.+(viewBox=["'](.+?)["'])/).flatten.last
178
+ coords = viewbox.split(' ')
179
+
180
+ {
181
+ viewBox: viewbox,
182
+ width: coords[2].to_i - coords[0].to_i,
183
+ height: coords[3].to_i - coords[1].to_i
184
+ }
185
+ else
186
+ {}
187
+ end
188
+ end
189
+
190
+ def pre_optimize(svg)
191
+ # Generate a regex of attributes to be removed
192
+ att = Regexp.new %w(xmlns xmlns:xlink xml:space version).map { |m| "#{m}=\".+?\"" }.join('|')
193
+
194
+ svg.strip
195
+ .gsub(att, '') # Remove unwanted attributes
196
+ .sub(/.+?<svg/,'<svg') # Get rid of doctypes and comments
197
+ .gsub(/style="([^"]*?)fill:(.+?);/m, 'fill="\2" style="\1') # Make fill a property instead of a style
198
+ .gsub(/style="([^"]*?)fill-opacity:(.+?);/m, 'fill-opacity="\2" style="\1') # Move fill-opacity a property instead of a style
199
+ .gsub(/\n/, '') # Remove endlines
200
+ .gsub(/\s{2,}/, ' ') # Remove whitespace
201
+ .gsub(/>\s+</, '><') # Remove whitespace between tags
202
+ .gsub(/\s?fill="(#0{3,6}|black|rgba?\(0,0,0\))"/,'') # Strip black fill
203
+ end
204
+
205
+ def post_optimize
206
+ @optimized = set_attributes
207
+ .gsub(/<\/svg/,'</symbol') # Replace svgs with symbols
208
+ .gsub(/class="def-/,'id="def-') # Replace <def> classes with ids (classes are generated in sub_def_ids)
209
+ .gsub(/\w+=""/,'') # Remove empty attributes
210
+ end
211
+
212
+ def set_attributes
213
+ attr.keys.each do |key|
214
+ @optimized.sub!(/ #{key}=".+?"/,'')
215
+ end
216
+
217
+ @optimized.sub!(/<svg/, "<symbol #{attributes(attr)}")
218
+ end
219
+
220
+ # Scans <def> blocks for IDs
221
+ # If urls(#id) are used, ensure these IDs are unique to this file
222
+ # Only replace IDs if urls exist to avoid replacing defs
223
+ # used in other svg files
224
+ #
225
+ def sub_def_ids
226
+ @optimized.scan(/<defs>.+<\/defs>/m).flatten.each do |defs|
227
+ defs.scan(/id="(.+?)"/).flatten.uniq.each_with_index do |id, index|
228
+
229
+ # If there are urls which refer to
230
+ # ids be sure to update both
231
+ #
232
+ if @optimized.match(/url\(##{id}\)/)
233
+ new_id = "def-#{@id}-#{index}"
234
+
235
+ @optimized.gsub! /id="#{id}"/, %Q{class="#{new_id}"}
236
+ @optimized.gsub! /url\(##{id}\)/, "url(##{new_id})"
237
+
238
+ # Otherwise just leave the IDs of the
239
+ # defs and change them to classes to
240
+ # avoid SVGO ID mangling
241
+ #
242
+ else
243
+ @optimized.gsub! /id="#{id}"/, %Q{class="#{id}"}
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
data/lib/esvg/utils.rb ADDED
@@ -0,0 +1,52 @@
1
+ module Esvg
2
+ module Utils
3
+ def dasherize(input)
4
+ input.gsub(/[\W,_]/, '-').sub(/^-/,'').gsub(/-{2,}/, '-')
5
+ end
6
+
7
+ def sub_path(root, path)
8
+ path.sub(File.join(root,''),'')
9
+ end
10
+
11
+ def symbolize_keys(hash)
12
+ h = {}
13
+ hash.each {|k,v| h[k.to_sym] = v }
14
+ h
15
+ end
16
+
17
+ def attributes(hash)
18
+ att = []
19
+ hash.each do |key, value|
20
+ att << %Q{#{key}="#{value}"} unless value.nil?
21
+ end
22
+ att.join(' ')
23
+ end
24
+
25
+ def sort(hash)
26
+ sorted = {}
27
+ hash.sort.each do |h|
28
+ sorted[h.first] = h.last
29
+ end
30
+ sorted
31
+ end
32
+
33
+ def compress(file)
34
+ mtime = File.mtime(file)
35
+ gz_file = "#{file}.gz"
36
+
37
+ return if (File.exist?(gz_file) && File.mtime(gz_file) >= mtime)
38
+
39
+ File.open(gz_file, "wb") do |dest|
40
+ gz = ::Zlib::GzipWriter.new(dest, Zlib::BEST_COMPRESSION)
41
+ gz.mtime = mtime.to_i
42
+ IO.copy_stream(open(file), gz)
43
+ gz.close
44
+ end
45
+
46
+ File.utime(mtime, mtime, gz_file)
47
+
48
+ gz_file
49
+ end
50
+
51
+ end
52
+ end
data/lib/esvg/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Esvg
2
- VERSION = "4.1.6"
2
+ VERSION = "4.2.0"
3
3
  end
data/lib/esvg.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  require "fileutils"
2
2
 
3
3
  require "esvg/version"
4
+ require "esvg/utils"
5
+ require "esvg/svgs"
4
6
  require "esvg/svg"
5
7
 
6
8
  if defined?(Rails)
@@ -12,21 +14,60 @@ module Esvg
12
14
  extend self
13
15
 
14
16
  def new(options={})
15
- @svgs = SVG.new(options)
17
+ @svgs ||=[]
18
+ @svgs << Svgs.new(options)
19
+ @svgs.last
16
20
  end
17
21
 
18
22
  def svgs
19
23
  @svgs
20
24
  end
21
25
 
22
- def embed(key)
23
- new.embed(key)
26
+ def use(name, options={})
27
+ if symbol = find_symbol(name, options)
28
+ html_safe symbol.use options
29
+ end
30
+ end
31
+
32
+ def use_tag(name, options={})
33
+ if symbol = find_symbol(name, options)
34
+ html_safe symbol.use_tag options
35
+ end
36
+ end
37
+
38
+ def embed(names=nil)
39
+ if rails? && Rails.env.production?
40
+ html_safe build_paths(names).each do |path|
41
+ javascript_include_tag(path, async: true)
42
+ end.join("\n")
43
+ else
44
+ html_safe find_svgs(names).map{|s| s.embed_script(names) }.join
45
+ end
46
+ end
47
+
48
+ def build_paths(names=nil)
49
+ find_svgs(names).map{|s| s.build_paths(names) }.flatten
50
+ end
51
+
52
+ def find_svgs(names=nil)
53
+ @svgs.select {|s| s.buildable_svgs(names) }
54
+ end
55
+
56
+ def find_symbol(name, options={})
57
+ if group = @svgs.find {|s| s.find_symbol(name, options[:fallback]) }
58
+ group.find_symbol(name, options[:fallback])
59
+ end
24
60
  end
25
61
 
26
62
  def rails?
27
63
  defined?(Rails)
28
64
  end
29
65
 
66
+ def html_safe(input)
67
+ input = input.html_safe if rails?
68
+ input
69
+ end
70
+
30
71
  def build(options={})
31
72
  new(options).build
32
73
  end
@@ -38,4 +79,22 @@ module Esvg
38
79
  end
39
80
  end
40
81
  end
82
+
83
+ # Determine if an NPM module is installed by checking paths with `npm bin`
84
+ # Returns path to binary if installed
85
+ def node_module(cmd)
86
+ @modules ||={}
87
+ return @modules[cmd] if !@modules[cmd].nil?
88
+
89
+ local = "$(npm bin)/#{cmd}"
90
+ global = "$(npm -g bin)/#{cmd}"
91
+
92
+ @modules[cmd] = if Open3.capture3(local)[2].success?
93
+ local
94
+ elsif Open3.capture3(global)[2].success?
95
+ global
96
+ else
97
+ false
98
+ end
99
+ end
41
100
  end