esvg 4.1.6 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
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