anyicon 1.1.0 → 1.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce30dcf29ae639c318492c5e089b62abb4889d45be351e3b24b34c6bd8573929
4
- data.tar.gz: 988ee32f96527a7acd237df49eaf7225752fa7786880f8dcf8f344ee97d950c1
3
+ metadata.gz: cf910787f8f2afc33957a422c85c68bb6453522921dd9d1bbe8af16abea8370a
4
+ data.tar.gz: 4b3a381baec2cfd44707c1ac7bb37d2ccb5b23fa607d05695c4e827582559320
5
5
  SHA512:
6
- metadata.gz: e17a8a1d3490f16c31445362d62f0c5b7d9a7149e1b4b5d950907fd1fed6ddab0cb02fc208b25a4881b8be9e025637e39a13cd00cdf7f0134f7ca49621da65c2
7
- data.tar.gz: 7734031fe6f47af1d7060dbcd8108f1c621e62a779080cb866976d185fc3282e1cb4aaf31639f65b695aaa669d98054de68b1c0fe12ef4eb8c87a121b1706386
6
+ metadata.gz: 60e1cb0856388ebf05ddd3cb4bd037445fc0e8191355079a8ee62b79f09f42e55b3be238484248af4a1578551b3fdc1e84e01a46c37e8481b84d325c9883ef72
7
+ data.tar.gz: '07293138a74f9cea182743a7700cd9cb8f3af4bd3e6dfd8fc2781b522ef1cc42c2f4d8fcb8a2cdd42cd583af9d48f81806271060b8f20b09b1441c97d0bb423e'
data/README.md CHANGED
@@ -66,6 +66,33 @@ Anyicon.configure do |config|
66
66
  end
67
67
  ```
68
68
 
69
+ Or add new collections while keeping the defaults:
70
+
71
+ ```ruby
72
+ Anyicon.configure do |config|
73
+ config.add_collections(
74
+ my_custom_collection: { repo: 'user/repo', path: 'path/to/icons', branch: 'main' }
75
+ )
76
+ end
77
+ ```
78
+
79
+ ### GitHub Token
80
+
81
+ Anyicon fetches icons from GitHub. Without authentication, the GitHub API limits requests to **60 per hour**. If you use `Anyicon::Collection` to download entire collections, or your app fetches many icons on first deploy, you may hit this limit.
82
+
83
+ Setting a GitHub personal access token raises the limit to **5,000 requests per hour**. The token is also used when downloading individual icons.
84
+
85
+ ```ruby
86
+ # config/initializers/anyicon.rb
87
+ Anyicon.configure do |config|
88
+ config.github_token = ENV["GITHUB_TOKEN"]
89
+ end
90
+ ```
91
+
92
+ To create a token, go to [GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens](https://github.com/settings/tokens?type=beta) and generate a token. No special permissions/scopes are needed — public repository read access is sufficient.
93
+
94
+ > **Note:** This is optional. If you only use a few icons and they are already cached locally in `app/assets/images/icons/`, no API calls are made and no token is needed.
95
+
69
96
  ## Collections Available
70
97
 
71
98
  | Collection | Github List | Example | Quantity | License |
@@ -90,13 +117,28 @@ Please, read the license before using any of these collections. This gem does no
90
117
 
91
118
  Fell free to add your own collection to this list.
92
119
 
120
+ ## Demo
121
+
122
+ To see Anyicon in action with an interactive demo page:
123
+
124
+ ```sh
125
+ git clone https://github.com/arthurmolina/anyicon.git
126
+ cd anyicon
127
+ bundle install
128
+ RAILS_ENV=development bundle exec rackup -p 3000 test/dummy/config.ru
129
+ ```
130
+
131
+ Then open [http://localhost:3000/demo](http://localhost:3000/demo).
132
+
133
+ The demo showcases basic usage, sizing, colors, HTML/data/aria attributes, inline text components, and CSS animations.
134
+
93
135
  ## Development
94
136
 
95
137
  To get started with development:
96
138
 
97
- ```
139
+ ```sh
98
140
  git clone https://github.com/arthurmolina/anyicon.git
99
- cd heroicon
141
+ cd anyicon
100
142
  bundle install
101
143
  bundle exec rake test
102
144
  ```
@@ -9,7 +9,7 @@ module Anyicon
9
9
  # @param props [Hash] additional properties to apply to the SVG element
10
10
  # @return [String] the rendered SVG icon
11
11
  def anyicon(icon = nil, **props)
12
- Anyicon::Icon.render(icon = icon, **props)
12
+ Anyicon::Icon.render(icon, **props)
13
13
  end
14
14
  end
15
15
  end
@@ -31,7 +31,8 @@ module Anyicon
31
31
  # @return [Array<Hash>] a list of icons with their metadata
32
32
  def list
33
33
  response = fetch(collection_url)
34
- JSON.parse(response&.body || "{}")
34
+ result = JSON.parse(response&.body || "[]")
35
+ result.is_a?(Array) ? result : []
35
36
  end
36
37
 
37
38
  # Downloads all icons in the collection and saves them to the local file system.
@@ -39,7 +40,7 @@ module Anyicon
39
40
  # @return [void]
40
41
  def download_all
41
42
  if list.empty?
42
- puts "No icons available."
43
+ ::Rails.logger.info "AnyIcon: No icons available for #{@collection}."
43
44
  return
44
45
  end
45
46
 
@@ -48,7 +49,7 @@ module Anyicon
48
49
  count += 1
49
50
  download(icon)
50
51
  end
51
- puts "#{@collection}: #{count} downloads."
52
+ ::Rails.logger.info "AnyIcon: #{@collection}: #{count} downloads."
52
53
  end
53
54
 
54
55
  # Retrieves the configured collections from Anyicon.
@@ -80,20 +81,24 @@ module Anyicon
80
81
  # @param icon_name [String] the name of the icon
81
82
  # @return [Pathname] the path to the icon file
82
83
  def icon_path(icon_name)
83
- ::Rails.root.join("app", "assets", "images", "icons", @collection.to_s, icon_name)
84
+ sanitized_collection = File.basename(@collection.to_s.gsub(/[^a-zA-Z0-9_\-]/, ""))
85
+ sanitized_name = File.basename(icon_name.to_s)
86
+ ::Rails.root.join("app", "assets", "images", "icons", sanitized_collection, sanitized_name)
84
87
  end
85
88
 
86
89
  # Constructs the URL to fetch the icon collection directory contents from the repository.
87
90
  #
88
91
  # @return [String, nil] the URL to fetch the collection contents, or nil if the collection is not configured
89
92
  def collection_url
90
- return nil unless collections.keys.include?(@collection)
93
+ return nil unless collections.key?(@collection)
91
94
 
92
95
  [
93
96
  "https://api.github.com/repos/",
94
97
  collections[@collection][:repo],
95
98
  "/contents/",
96
- collections[@collection][:path]
99
+ collections[@collection][:path],
100
+ "?ref=",
101
+ collections[@collection][:branch]
97
102
  ].join("")
98
103
  end
99
104
  end
@@ -12,6 +12,8 @@ module Anyicon
12
12
  # response = common.fetch('https://example.com')
13
13
  # puts response.body if response.is_a?(Net::HTTPSuccess)
14
14
  class Common
15
+ ALLOWED_HOSTS = %w[github.com raw.githubusercontent.com objects.githubusercontent.com api.github.com].freeze
16
+
15
17
  # Fetches the content from the given URL, following redirects if necessary.
16
18
  #
17
19
  # @param url [String] the URL to fetch
@@ -19,11 +21,21 @@ module Anyicon
19
21
  # @return [Net::HTTPResponse] the HTTP response
20
22
  # @raise [Net::HTTPError] if the number of redirects exceeds the limit or another HTTP error occurs
21
23
  def fetch(url, limit = 10)
22
- raise Net::HTTPError, "Too many HTTP redirects" if limit.zero?
24
+ raise Net::HTTPError.new("Too many HTTP redirects", nil) if limit.zero?
23
25
  return nil if url.nil?
24
26
 
25
- uri = URI.parse(URI::DEFAULT_PARSER.escape(url))
26
- response = Net::HTTP.get_response(uri)
27
+ uri = URI.parse(URI::RFC2396_PARSER.escape(url))
28
+ unless uri.is_a?(URI::HTTPS) && ALLOWED_HOSTS.include?(uri.host)
29
+ raise Net::HTTPError.new("Blocked request to disallowed host: #{uri.host}", nil)
30
+ end
31
+
32
+ request = Net::HTTP::Get.new(uri)
33
+ token = Anyicon.configuration.github_token
34
+ request["Authorization"] = "token #{token}" if token
35
+
36
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
37
+ http.request(request)
38
+ end
27
39
 
28
40
  case response
29
41
  when Net::HTTPSuccess then response
@@ -42,9 +42,13 @@ module Anyicon
42
42
  # @return [Hash] the configured icon collections
43
43
  attr_accessor :collections
44
44
 
45
+ # @return [String, nil] optional GitHub personal access token for API requests
46
+ attr_accessor :github_token
47
+
45
48
  # Initializes a new Configuration instance with default settings.
46
49
  def initialize
47
50
  @collections = DEFAULT_COLLECTIONS.dup
51
+ @github_token = nil
48
52
  end
49
53
 
50
54
  def add_collections(new_collections)
data/lib/anyicon/icon.rb CHANGED
@@ -21,23 +21,29 @@ module Anyicon
21
21
  #
22
22
  # @param icon [String] a comma-separated string of icon names, each in the format 'collection:name'
23
23
  # @param props [Hash] additional properties to apply to the SVG element
24
+ ALLOWED_ATTRIBUTES = %w[
25
+ class id style width height viewBox fill stroke
26
+ stroke-width opacity transform title aria-label aria-hidden role
27
+ ].freeze
28
+
29
+ DATA_ATTRIBUTE_PATTERN = /\Adata-[\w-]+\z/
30
+
24
31
  def initialize(icon = nil, **props)
25
- # binding.pry
26
32
  super()
27
33
  @icons = (icon || props[:icon]).to_s.split(",").map { |i| i.split(":") }
28
- @props = props
34
+ @icons.reject! { |i| i.length < 2 }
35
+ @props = props.select { |key, _| allowed_attribute?(key) }
29
36
  end
30
37
 
31
38
  # Renders the SVG content for the specified icons.
32
39
  #
33
40
  # @return [String] the HTML-safe SVG content
34
41
  def render
35
- result = "".html_safe
36
- @icons.each do |icon|
42
+ parts = @icons.map do |icon|
37
43
  ensure_icon_exists(icon)
38
- result.concat(svg_content(icon).html_safe)
44
+ svg_content(icon)
39
45
  end
40
- result
46
+ ActiveSupport::SafeBuffer.new(parts.join)
41
47
  end
42
48
 
43
49
  private
@@ -49,6 +55,23 @@ module Anyicon
49
55
  @collections ||= Anyicon.configuration.collections
50
56
  end
51
57
 
58
+ # Checks if an attribute key is allowed on the SVG element.
59
+ #
60
+ # @param key [Symbol, String] the attribute key
61
+ # @return [Boolean]
62
+ def allowed_attribute?(key)
63
+ name = key.to_s
64
+ ALLOWED_ATTRIBUTES.include?(name) || name.match?(DATA_ATTRIBUTE_PATTERN)
65
+ end
66
+
67
+ # Sanitizes an icon name component to prevent path traversal.
68
+ #
69
+ # @param name [String] the raw icon name component
70
+ # @return [String] the sanitized name
71
+ def sanitize_name(name)
72
+ File.basename(name.to_s.gsub(/[^a-zA-Z0-9_\-]/, ""))
73
+ end
74
+
52
75
  # Ensures the specified icon exists by downloading it if necessary.
53
76
  #
54
77
  # @param icon [Array] the collection and name of the icon
@@ -77,7 +100,7 @@ module Anyicon
77
100
  # @param icon [Array] the collection and name of the icon
78
101
  # @return [Pathname] the path to the icon file
79
102
  def icon_path(icon)
80
- ::Rails.root.join("app", "assets", "images", "icons", icon[0], "#{icon[1]}.svg")
103
+ ::Rails.root.join("app", "assets", "images", "icons", sanitize_name(icon[0]), "#{sanitize_name(icon[1])}.svg")
81
104
  end
82
105
 
83
106
  # Constructs the URL to download the specified icon.
@@ -85,10 +108,22 @@ module Anyicon
85
108
  # @param icon [Array] the collection and name of the icon
86
109
  # @return [String, nil] the URL to download the icon, or nil if the collection is not configured
87
110
  def icon_url(icon)
88
- return nil unless collections.keys.include?(icon[0].to_sym)
111
+ collection_key = sanitize_name(icon[0]).to_sym
112
+ return nil unless collections.key?(collection_key)
113
+
114
+ [ "https://github.com/", collections[collection_key][:repo], "/raw/", collections[collection_key][:branch], "/",
115
+ collections[collection_key][:path], "/", sanitize_name(icon[1]), ".svg" ].join("")
116
+ end
89
117
 
90
- [ "https://github.com/", collections[icon[0].to_sym][:repo], "/raw/", collections[icon[0].to_sym][:branch], "/",
91
- collections[icon[0].to_sym][:path], "/", icon[1], ".svg" ].join("")
118
+ # Returns the cached raw SVG content for the specified icon.
119
+ #
120
+ # @param icon [Array] the collection and name of the icon
121
+ # @return [String, nil] the raw SVG file content, or nil if file doesn't exist
122
+ def cached_svg(icon)
123
+ path = icon_path(icon)
124
+ return nil unless File.file?(path)
125
+
126
+ self.class.svg_cache[path.to_s] ||= File.read(path)
92
127
  end
93
128
 
94
129
  # Reads and customizes the SVG content for the specified icon.
@@ -96,10 +131,10 @@ module Anyicon
96
131
  # @param icon [Array] the collection and name of the icon
97
132
  # @return [String] the customized SVG content
98
133
  def svg_content(icon)
99
- return "" unless File.file?(icon_path(icon))
134
+ raw = cached_svg(icon)
135
+ return "" unless raw
100
136
 
101
- svg_content = File.read(icon_path(icon))
102
- doc = Nokogiri::HTML::DocumentFragment.parse(svg_content)
137
+ doc = Nokogiri::HTML::DocumentFragment.parse(raw)
103
138
  svg = doc.at_css "svg"
104
139
 
105
140
  @props.each do |key, value|
@@ -119,6 +154,19 @@ module Anyicon
119
154
  def render(*args, **kwargs)
120
155
  new(*args, **kwargs).render
121
156
  end
157
+
158
+ # In-memory cache for raw SVG file contents, keyed by file path.
159
+ # Avoids repeated File.read + disk I/O for the same icon.
160
+ #
161
+ # @return [Hash] the SVG cache
162
+ def svg_cache
163
+ @svg_cache ||= {}
164
+ end
165
+
166
+ # Clears the in-memory SVG cache.
167
+ def clear_cache!
168
+ @svg_cache = {}
169
+ end
122
170
  end
123
171
  end
124
172
  end
@@ -1,3 +1,3 @@
1
1
  module Anyicon
2
- VERSION = "1.1.0"
2
+ VERSION = "1.1.1"
3
3
  end
@@ -15,4 +15,11 @@ Anyicon.configure do |config|
15
15
  # }
16
16
  # )
17
17
  #
18
+
19
+ ##
20
+ # Optional: Set a GitHub personal access token to avoid API rate limits
21
+ # (60 requests/hour unauthenticated vs 5,000/hour authenticated).
22
+ #
23
+ # config.github_token = ENV["GITHUB_TOKEN"]
24
+ #
18
25
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anyicon
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arthur Molina
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-07-16 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: rails
@@ -24,6 +23,20 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '5.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: nokogiri
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
27
40
  - !ruby/object:Gem::Dependency
28
41
  name: appraisal
29
42
  requirement: !ruby/object:Gem::Requirement
@@ -139,7 +152,6 @@ metadata:
139
152
  source_code_uri: https://github.com/arthurmolina/anyicon
140
153
  bug_tracker_uri: https://github.com/arthurmolina/anyicon/issues
141
154
  wiki_uri: https://github.com/arthurmolina/anyicon/wiki
142
- post_install_message:
143
155
  rdoc_options: []
144
156
  require_paths:
145
157
  - lib
@@ -154,8 +166,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
154
166
  - !ruby/object:Gem::Version
155
167
  version: '0'
156
168
  requirements: []
157
- rubygems_version: 3.5.16
158
- signing_key:
169
+ rubygems_version: 3.6.9
159
170
  specification_version: 4
160
171
  summary: Rails View Helpers for any icon collections.
161
172
  test_files: []