svg_icon 0.2.0 → 0.4.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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fetcher"
4
+
5
+ module SvgIcon
6
+ class CLI
7
+ def self.run(argv)
8
+ new(argv).run
9
+ end
10
+
11
+ def initialize(argv)
12
+ @argv = argv
13
+ end
14
+
15
+ def run
16
+ command = @argv.shift
17
+ case command
18
+ when "fetch"
19
+ fetch
20
+ when nil, "help", "--help", "-h"
21
+ puts usage
22
+ else
23
+ warn "Unknown command: #{command}"
24
+ warn usage
25
+ exit 1
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def fetch
32
+ name = @argv.shift
33
+ unless valid_name?(name) && @argv.empty?
34
+ warn "Usage: svg_icon fetch <icon_set_name>"
35
+ exit 1
36
+ end
37
+
38
+ destination = File.join(SvgIcon.configuration.icons_path, "#{name}.json")
39
+ puts "Fetching #{name} from iconify/icon-sets"
40
+ SvgIcon::Fetcher.new.fetch(name, destination)
41
+ puts "Saved to #{relative_path(destination)}"
42
+ rescue SvgIcon::FetchError => e
43
+ warn e.message
44
+ exit 1
45
+ rescue StandardError => e
46
+ warn "Error: #{e.message}"
47
+ exit 1
48
+ end
49
+
50
+ def valid_name?(name)
51
+ !name.nil? && name =~ SvgIcon::Fetcher::NAME_PATTERN
52
+ end
53
+
54
+ def relative_path(path)
55
+ path.sub("#{Dir.pwd}/", "")
56
+ end
57
+
58
+ def usage
59
+ <<~TEXT
60
+ Usage: svg_icon COMMAND
61
+
62
+ Commands:
63
+ fetch NAME Download icon set NAME (e.g. bi) from iconify/icon-sets into #{SvgIcon.configuration.icons_path}
64
+ help Show this help
65
+ TEXT
66
+ end
67
+ end
68
+ end
@@ -2,14 +2,16 @@
2
2
 
3
3
  module SvgIcon
4
4
  class Configuration
5
- DEFAULT_ICON = "bi"
5
+ DEFAULT_ICON = "lucide"
6
6
 
7
7
  attr_accessor :icon
8
8
  attr_accessor :default_class
9
9
  attr_accessor :extra_icons_path
10
+ attr_accessor :icons_path
10
11
 
11
12
  def initialize
12
13
  @icon = DEFAULT_ICON
14
+ @icons_path = File.join(Dir.pwd, "config", "svg_icons")
13
15
  end
14
16
  end
15
17
 
@@ -23,5 +25,6 @@ module SvgIcon
23
25
 
24
26
  def self.configure
25
27
  yield configuration
28
+ SvgIcon.clear_cache!
26
29
  end
27
30
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "tempfile"
7
+ require "uri"
8
+
9
+ module SvgIcon
10
+ class FetchError < Error; end
11
+
12
+ class Fetcher
13
+ DEFAULT_BASE_URL = "https://raw.githubusercontent.com/iconify/icon-sets/master/json"
14
+ NAME_PATTERN = /\A[a-z0-9\-_]+\z/
15
+
16
+ def initialize(base_url: DEFAULT_BASE_URL, http: Net::HTTP)
17
+ @base_url = base_url
18
+ @http = http
19
+ end
20
+
21
+ def fetch(name, destination)
22
+ validate_name!(name)
23
+ response = fetch_response(name)
24
+ raise FetchError, "Failed to fetch #{name}: HTTP #{response.code}#{not_found_hint(response.code)}" unless response.code == "200"
25
+
26
+ parse(response.body, name)
27
+ write(response.body, destination)
28
+ true
29
+ end
30
+
31
+ private
32
+
33
+ def validate_name!(name)
34
+ raise FetchError, "Invalid icon set name: #{name}" unless name.is_a?(String) && name =~ NAME_PATTERN
35
+ end
36
+
37
+ def fetch_response(name)
38
+ @http.get_response(uri_for(name))
39
+ rescue SocketError, SystemCallError, Timeout::Error, EOFError, IOError, OpenSSL::SSL::SSLError => e
40
+ raise FetchError, "Failed to fetch #{name}: #{e.message}"
41
+ end
42
+
43
+ def uri_for(name)
44
+ URI.join("#{@base_url}/", "#{name}.json")
45
+ end
46
+
47
+ def not_found_hint(code)
48
+ code == "404" ? " (icon set not found)" : ""
49
+ end
50
+
51
+ def parse(body, name)
52
+ data = MultiJson.load(body)
53
+ return if data.is_a?(Hash) && data["icons"].is_a?(Hash)
54
+
55
+ raise FetchError, "Invalid icon set '#{name}': JSON must contain an 'icons' object"
56
+ rescue MultiJson::ParseError => e
57
+ raise FetchError, "Invalid icon set '#{name}': invalid JSON (#{e.message})"
58
+ end
59
+
60
+ def write(body, destination)
61
+ dir = File.dirname(destination)
62
+ FileUtils.mkdir_p(dir)
63
+ temp = Tempfile.new([".#{File.basename(destination)}", ".tmp"], dir)
64
+ begin
65
+ temp.write(body)
66
+ temp.flush
67
+ File.rename(temp.path, destination)
68
+ ensure
69
+ temp.close!
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,31 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
1
5
  module SvgIcon
2
6
  module Helper
3
7
  def svg_icon(name, options = {})
4
8
  options = options.dup
5
-
6
9
  icons = SvgIcon.icons_json
10
+
7
11
  height = icons["height"] || 24
8
12
  width = icons["width"] || 24
9
13
 
10
- options[:viewBox] = "0 0 #{width} #{height}"
11
- options[:version] = "1.1"
14
+ options[:viewBox] ||= "0 0 #{width} #{height}"
12
15
 
13
16
  default_class = SvgIcon.configuration.default_class
14
-
15
17
  options[:class] = "#{default_class} #{options[:class]}".strip if default_class.present?
16
18
 
17
- begin
18
- path = icons["icons"][name]["body"]
19
- "<svg xmlns='http://www.w3.org/2000/svg' #{icon_html_attributes(options)}>#{path}</svg>".html_safe
20
- rescue
21
- "<svg xmlns='http://www.w3.org/2000/svg' #{icon_html_attributes(options)}><!-- SVG icon file not found: '#{name}' --></svg>".html_safe
19
+ body = icons.dig("icons", name.to_s, "body")
20
+
21
+ content = if body
22
+ body
23
+ else
24
+ "<!-- SVG icon file not found: '#{safe_comment_text(name.to_s)}' -->"
22
25
  end
26
+
27
+ "<svg xmlns='http://www.w3.org/2000/svg' #{icon_html_attributes(options)}>#{content}</svg>".html_safe
23
28
  end
24
29
 
30
+ private
31
+
25
32
  def icon_html_attributes(options)
26
- attrs = ""
27
- options.each { |attr, value| attrs += "#{attr}=\"#{value}\" " }
28
- attrs.strip
33
+ options.map do |attr, value|
34
+ %(#{CGI.escapeHTML(attr.to_s)}="#{CGI.escapeHTML(value.to_s)}")
35
+ end.join(" ")
36
+ end
37
+
38
+ def safe_comment_text(value)
39
+ CGI.escapeHTML(value).gsub("-", "&#45;")
29
40
  end
30
41
  end
31
42
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SvgIcon
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/svg_icon.rb CHANGED
@@ -5,50 +5,106 @@ require_relative "svg_icon/helper"
5
5
  require_relative "svg_icon/version"
6
6
 
7
7
  require "multi_json"
8
+ require "active_support/core_ext/object/blank"
9
+ require "active_support/core_ext/string/output_safety"
8
10
 
9
11
  module SvgIcon
10
12
  class Error < StandardError; end
11
13
 
14
+ require_relative "svg_icon/fetcher"
15
+ require_relative "svg_icon/cli"
16
+
12
17
  extend self
13
18
 
14
- def extra_file_data
15
- extra_data_path = SvgIcon.configuration.extra_icons_path
16
- return nil if extra_data_path.nil?
17
- @extra_file_data ||= File.read(extra_data_path)
19
+ def icons
20
+ @icons ||= {}
21
+ @icons[file_cache_key_for(resolve_icon_path)] ||= MultiJson.load(file_data)
18
22
  end
19
23
 
20
- def extra_icons
21
- return {} if extra_file_data.nil?
22
- @extra_icons ||= MultiJson.load(extra_file_data)
24
+ def icons_json
25
+ @icons_json ||= {}
26
+ @icons_json[cache_key] ||= merge_extra_icons(icons)
23
27
  end
24
28
 
25
- # def file_data
26
- # icons_data_path = SvgIcon.configuration.icons_data_path
29
+ def clear_cache!
30
+ @file_data = nil
31
+ @icons = nil
32
+ @icons_json = nil
33
+ @extra_file_data = nil
34
+ @extra_icons = nil
35
+ end
36
+
37
+ private
27
38
 
28
- # icons_path = if icons_data_path.present?
29
- # File.join(icons_data_path)
30
- # else
31
- # File.join(__dir__, "data/#{SvgIcon.configuration.icon}.json")
32
- # end
33
- # @file_data ||= File.read(icons_path)
34
- # end
39
+ def icon
40
+ configuration.icon
41
+ end
42
+
43
+ def configuration
44
+ SvgIcon.configuration
45
+ end
35
46
 
36
47
  def file_data
37
- icons_path = File.join(__dir__, "data/#{SvgIcon.configuration.icon}.json")
38
- @file_data ||= File.read(icons_path)
48
+ @file_data ||= {}
49
+ @file_data[file_cache_key_for(resolve_icon_path)] ||= begin
50
+ path = resolve_icon_path
51
+ raise Error, "Icon data file not found: #{icon} (looked in #{configuration.icons_path} and bundled data)" unless File.exist?(path)
52
+
53
+ File.read(path)
54
+ end
39
55
  end
40
56
 
41
- def icons
42
- @icons ||= MultiJson.load(file_data)
57
+ def resolve_icon_path
58
+ external = File.join(configuration.icons_path, "#{icon}.json")
59
+ return external if File.exist?(external)
60
+
61
+ File.join(__dir__, "data", "#{icon}.json")
43
62
  end
44
63
 
45
- def icons_json
46
- @icons_json ||= if extra_icons
47
- puts "icons_json"
48
- icons["icons"] = icons["icons"].merge(extra_icons)
49
- icons
50
- else
51
- icons
64
+ def file_cache_key_for(path)
65
+ return nil unless path
66
+
67
+ "#{path}:#{File.exist?(path) ? File.mtime(path).to_i : nil}"
68
+ end
69
+
70
+ def extra_icons
71
+ return {} unless configuration.extra_icons_path
72
+
73
+ @extra_icons ||= {}
74
+ @extra_icons[file_cache_key_for(extra_path)] ||= begin
75
+ data = MultiJson.load(extra_file_data)
76
+ raise Error, "Extra icons file must contain a JSON object: #{extra_path}" unless data.is_a?(Hash)
77
+
78
+ data
52
79
  end
53
80
  end
81
+
82
+ def extra_path
83
+ configuration.extra_icons_path
84
+ end
85
+
86
+ def extra_file_data
87
+ @extra_file_data ||= {}
88
+ @extra_file_data[file_cache_key_for(extra_path)] ||= begin
89
+ path = extra_path
90
+ raise Error, "Extra icons file not found: #{path}" unless File.exist?(path)
91
+
92
+ File.read(path)
93
+ end
94
+ end
95
+
96
+ def merge_extra_icons(base_icons)
97
+ icons_set = base_icons["icons"]
98
+ raise Error, "Icon data must contain an 'icons' object: #{resolve_icon_path}" unless icons_set.is_a?(Hash)
99
+
100
+ return base_icons if extra_icons.empty?
101
+
102
+ merged = base_icons.dup
103
+ merged["icons"] = icons_set.merge(extra_icons)
104
+ merged
105
+ end
106
+
107
+ def cache_key
108
+ "#{icon}:#{file_cache_key_for(resolve_icon_path)}:#{file_cache_key_for(extra_path)}"
109
+ end
54
110
  end
data/svg_icon.gemspec CHANGED
@@ -26,6 +26,8 @@ Gem::Specification.new do |spec|
26
26
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27
27
  spec.require_paths = ["lib"]
28
28
 
29
- # Uncomment to register a new dependency of your gem
30
29
  spec.add_dependency "multi_json"
30
+ spec.add_dependency "activesupport", ">= 5.0"
31
+
32
+ spec.add_development_dependency "minitest"
31
33
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: svg_icon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - doabit
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2022-03-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: multi_json
@@ -24,10 +23,39 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
27
54
  description: Svg icon render helper for rails.
28
55
  email:
29
56
  - doinsist@gmail.com
30
- executables: []
57
+ executables:
58
+ - svg_icon
31
59
  extensions: []
32
60
  extra_rdoc_files: []
33
61
  files:
@@ -38,11 +66,17 @@ files:
38
66
  - Rakefile
39
67
  - bin/console
40
68
  - bin/setup
69
+ - docs/superpowers/plans/2026-08-18-svg-icon-fetch-cli.md
70
+ - docs/superpowers/specs/2026-08-18-svg-icon-fetch-design.md
71
+ - exe/svg_icon
41
72
  - lib/data/bi.json
42
73
  - lib/data/bx.json
43
74
  - lib/data/heroicons.json
75
+ - lib/data/lucide.json
44
76
  - lib/svg_icon.rb
77
+ - lib/svg_icon/cli.rb
45
78
  - lib/svg_icon/configuration.rb
79
+ - lib/svg_icon/fetcher.rb
46
80
  - lib/svg_icon/helper.rb
47
81
  - lib/svg_icon/version.rb
48
82
  - svg_icon.gemspec
@@ -52,7 +86,6 @@ metadata:
52
86
  homepage_uri: https://github.com/ruby-gems/svg_icon
53
87
  source_code_uri: https://github.com/ruby-gems/svg_icon
54
88
  changelog_uri: https://github.com/ruby-gems/svg_icon
55
- post_install_message:
56
89
  rdoc_options: []
57
90
  require_paths:
58
91
  - lib
@@ -67,8 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
100
  - !ruby/object:Gem::Version
68
101
  version: '0'
69
102
  requirements: []
70
- rubygems_version: 3.2.3
71
- signing_key:
103
+ rubygems_version: 3.6.9
72
104
  specification_version: 4
73
105
  summary: Svg icon render helper for rails
74
106
  test_files: []