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.
- checksums.yaml +4 -4
- data/Gemfile.lock +35 -2
- data/README.md +16 -4
- data/Rakefile +9 -1
- data/docs/superpowers/plans/2026-08-18-svg-icon-fetch-cli.md +557 -0
- data/docs/superpowers/specs/2026-08-18-svg-icon-fetch-design.md +100 -0
- data/exe/svg_icon +7 -0
- data/lib/data/lucide.json +6268 -0
- data/lib/svg_icon/cli.rb +68 -0
- data/lib/svg_icon/configuration.rb +4 -1
- data/lib/svg_icon/fetcher.rb +73 -0
- data/lib/svg_icon/helper.rb +23 -12
- data/lib/svg_icon/version.rb +1 -1
- data/lib/svg_icon.rb +83 -27
- data/svg_icon.gemspec +3 -1
- metadata +39 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b388bb80ccfff538510163078ff382d07908549abaa76da0382af3ee7bc1ed07
|
|
4
|
+
data.tar.gz: b646054bc7c5035c49f51dd4e6845154aba0390d0d37d5804462e0869ef74ebf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88cad87bd6dbad2faf9ac6458f9f0b30cb0e851df11eaac75034b0b85bd068a0cd820b4ea41d860f012ea697d923f3c6f10fdf44d6df5274cea4adaff46c5686
|
|
7
|
+
data.tar.gz: 527479eb7d5a90b3a10d4473298db0d166acefac938d4f227c40fc76e49872be8980f44f7c24cff4f9cc0017cc1fc67c53c149e574afcc84575dc3358cb0b4c1
|
data/Gemfile.lock
CHANGED
|
@@ -1,21 +1,54 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
svg_icon (0.
|
|
4
|
+
svg_icon (0.4.0)
|
|
5
|
+
activesupport (>= 5.0)
|
|
5
6
|
multi_json
|
|
6
7
|
|
|
7
8
|
GEM
|
|
8
9
|
remote: https://rubygems.org/
|
|
9
10
|
specs:
|
|
11
|
+
activesupport (8.1.3.1)
|
|
12
|
+
base64
|
|
13
|
+
bigdecimal
|
|
14
|
+
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
15
|
+
connection_pool (>= 2.2.5)
|
|
16
|
+
drb
|
|
17
|
+
i18n (>= 1.6, < 2)
|
|
18
|
+
json
|
|
19
|
+
logger (>= 1.4.2)
|
|
20
|
+
minitest (>= 5.1)
|
|
21
|
+
securerandom (>= 0.3)
|
|
22
|
+
tzinfo (~> 2.0, >= 2.0.5)
|
|
23
|
+
uri (>= 0.13.1)
|
|
24
|
+
base64 (0.3.0)
|
|
25
|
+
bigdecimal (4.1.2)
|
|
26
|
+
concurrent-ruby (1.3.8)
|
|
27
|
+
connection_pool (3.0.2)
|
|
28
|
+
drb (2.2.3)
|
|
29
|
+
i18n (1.15.2)
|
|
30
|
+
concurrent-ruby (~> 1.0)
|
|
31
|
+
json (2.21.2)
|
|
32
|
+
logger (1.7.0)
|
|
33
|
+
minitest (6.0.6)
|
|
34
|
+
drb (~> 2.0)
|
|
35
|
+
prism (~> 1.5)
|
|
10
36
|
multi_json (1.15.0)
|
|
37
|
+
prism (1.9.0)
|
|
11
38
|
rake (13.0.6)
|
|
39
|
+
securerandom (0.4.1)
|
|
40
|
+
tzinfo (2.0.6)
|
|
41
|
+
concurrent-ruby (~> 1.0)
|
|
42
|
+
uri (1.1.1)
|
|
12
43
|
|
|
13
44
|
PLATFORMS
|
|
45
|
+
arm64-darwin-24
|
|
14
46
|
x86_64-darwin-18
|
|
15
47
|
|
|
16
48
|
DEPENDENCIES
|
|
49
|
+
minitest
|
|
17
50
|
rake (~> 13.0)
|
|
18
51
|
svg_icon!
|
|
19
52
|
|
|
20
53
|
BUNDLED WITH
|
|
21
|
-
|
|
54
|
+
4.0.16
|
data/README.md
CHANGED
|
@@ -22,11 +22,10 @@ Or install it yourself as:
|
|
|
22
22
|
|
|
23
23
|
Add `svg_icon.rb` in initializers folder
|
|
24
24
|
|
|
25
|
-
```
|
|
26
|
-
# frozen_string_literal: true
|
|
27
|
-
|
|
25
|
+
```ruby
|
|
28
26
|
SvgIcon.configure do |config|
|
|
29
|
-
# config.icon = "
|
|
27
|
+
# config.icon = "lucide" # icon set name: "lucide", "bi", "bx", "heroicons", or any fetched set
|
|
28
|
+
config.icons_path = Rails.root.join("config", "svg_icons") # defaults to "config/svg_icons" under the project root
|
|
30
29
|
|
|
31
30
|
##
|
|
32
31
|
# You can set a default class for icon
|
|
@@ -40,6 +39,19 @@ add ` include SvgIcon::Helper` to `ApplicationHelper`
|
|
|
40
39
|
<%= svg_icon("search") %>
|
|
41
40
|
```
|
|
42
41
|
|
|
42
|
+
## Fetching icon sets
|
|
43
|
+
|
|
44
|
+
The gem bundles a few icon sets (lucide, bi, bx, heroicons). To use any other
|
|
45
|
+
[iconify icon set](https://github.com/iconify/icon-sets/tree/master/json), download it into your project:
|
|
46
|
+
|
|
47
|
+
$ svg_icon fetch bi
|
|
48
|
+
|
|
49
|
+
This downloads `bi.json` into `config/svg_icons/`. Set `config.icon = "bi"` to use it —
|
|
50
|
+
the gem looks for `<icon>.json` in `config/svg_icons/` first, then falls back to bundled data.
|
|
51
|
+
|
|
52
|
+
Commit `config/svg_icons/` to your repository so deploys don't need to re-fetch.
|
|
53
|
+
Re-run `svg_icon fetch <name>` to update an existing set.
|
|
54
|
+
|
|
43
55
|
## Development
|
|
44
56
|
|
|
45
57
|
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/Rakefile
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
-
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test" << "lib"
|
|
8
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
9
|
+
t.verbose = true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
task default: :test
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
# svg_icon fetch CLI Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a `svg_icon fetch <name>` CLI that downloads icon sets from iconify/icon-sets into the project, usable as independent icon sets via config.
|
|
6
|
+
|
|
7
|
+
**Architecture:** New `SvgIcon::Fetcher` class (net/http, injectable for tests) handles download/validate/atomic-write. New `SvgIcon::CLI` class + `exe/svg_icon` thin wrapper dispatch `fetch`. Configuration gains `icons_path`; `file_data` resolution order becomes external `icons_path/<icon>.json` → bundled `lib/data/<icon>.json` → raise.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, minitest, net/http (stdlib), multi_json, OptionParser-free manual dispatch (tiny surface).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-18-svg-icon-fetch-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### Task 1: `icons_path` config + external-first data resolution
|
|
16
|
+
|
|
17
|
+
**Files:**
|
|
18
|
+
- Modify: `lib/svg_icon/configuration.rb`
|
|
19
|
+
- Modify: `lib/svg_icon.rb`
|
|
20
|
+
- Test: `test/svg_icon_test.rb`
|
|
21
|
+
|
|
22
|
+
- [ ] **Step 1: Write the failing tests**
|
|
23
|
+
|
|
24
|
+
Append to `test/svg_icon_test.rb`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
def test_default_icons_path_points_to_project_config_dir
|
|
28
|
+
assert_equal File.join(Dir.pwd, "config", "svg_icons"), SvgIcon.configuration.icons_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_icons_loads_from_icons_path_when_present
|
|
32
|
+
Dir.mktmpdir do |dir|
|
|
33
|
+
File.write(File.join(dir, "custom.json"), %({"prefix":"custom","icons":{"x":{"body":"1"}}}))
|
|
34
|
+
SvgIcon.configure { |config| config.icon = "custom"; config.icons_path = dir }
|
|
35
|
+
assert_equal "custom", SvgIcon.icons["prefix"]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_icons_falls_back_to_bundled_data
|
|
40
|
+
Dir.mktmpdir do |dir|
|
|
41
|
+
SvgIcon.configure { |config| config.icon = "lucide"; config.icons_path = dir }
|
|
42
|
+
assert_equal "lucide", SvgIcon.icons["prefix"]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_icons_raises_when_file_missing_everywhere
|
|
47
|
+
Dir.mktmpdir do |dir|
|
|
48
|
+
SvgIcon.configure { |config| config.icon = "nonexistent"; config.icons_path = dir }
|
|
49
|
+
error = assert_raises(SvgIcon::Error) { SvgIcon.icons }
|
|
50
|
+
assert_match(/Icon data file not found/, error.message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
56
|
+
|
|
57
|
+
Run: `bundle exec ruby -Ilib -Itest test/svg_icon_test.rb`
|
|
58
|
+
Expected: `test_default_icons_path...` FAILS with `NoMethodError: undefined method 'icons_path'`; the other three fail with `Icon data file not found: .../lib/data/custom.json` (external dir never consulted).
|
|
59
|
+
|
|
60
|
+
- [ ] **Step 3: Add `icons_path` to Configuration**
|
|
61
|
+
|
|
62
|
+
In `lib/svg_icon/configuration.rb`, change the class to:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class Configuration
|
|
66
|
+
DEFAULT_ICON = "lucide"
|
|
67
|
+
|
|
68
|
+
attr_accessor :icon
|
|
69
|
+
attr_accessor :default_class
|
|
70
|
+
attr_accessor :extra_icons_path
|
|
71
|
+
attr_accessor :icons_path
|
|
72
|
+
|
|
73
|
+
def initialize
|
|
74
|
+
@icon = DEFAULT_ICON
|
|
75
|
+
@icons_path = File.join(Dir.pwd, "config", "svg_icons")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- [ ] **Step 4: External-first resolution in `lib/svg_icon.rb`**
|
|
81
|
+
|
|
82
|
+
Replace the `data_path` and `file_data` methods (currently `data_path` builds `File.join(__dir__, "data", ...)` and `file_data` memoizes by path) with:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
def file_data
|
|
86
|
+
@file_data ||= {}
|
|
87
|
+
@file_data[icon] ||= begin
|
|
88
|
+
path = resolve_icon_path
|
|
89
|
+
raise Error, "Icon data file not found: #{path}" unless File.exist?(path)
|
|
90
|
+
|
|
91
|
+
File.read(path)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_icon_path
|
|
96
|
+
external = File.join(configuration.icons_path, "#{icon}.json")
|
|
97
|
+
return external if File.exist?(external)
|
|
98
|
+
|
|
99
|
+
File.join(__dir__, "data", "#{icon}.json")
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then in `merge_extra_icons` (same file), replace `#{data_path}` with `#{resolve_icon_path}` in the error message. Delete the now-unused `data_path` method.
|
|
104
|
+
|
|
105
|
+
- [ ] **Step 5: Run full test suite**
|
|
106
|
+
|
|
107
|
+
Run: `bundle exec rake test`
|
|
108
|
+
Expected: all 26 tests pass (22 existing + 4 new).
|
|
109
|
+
|
|
110
|
+
- [ ] **Step 6: Commit**
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
git add lib/svg_icon.rb lib/svg_icon/configuration.rb test/svg_icon_test.rb
|
|
114
|
+
git commit -m "feat: support external icon sets via icons_path config"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### Task 2: `SvgIcon::Fetcher`
|
|
120
|
+
|
|
121
|
+
**Files:**
|
|
122
|
+
- Create: `lib/svg_icon/fetcher.rb`
|
|
123
|
+
- Create: `test/fetcher_test.rb`
|
|
124
|
+
- Modify: `lib/svg_icon.rb` (require fetcher)
|
|
125
|
+
|
|
126
|
+
- [ ] **Step 1: Write the failing tests**
|
|
127
|
+
|
|
128
|
+
Create `test/fetcher_test.rb`:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# frozen_string_literal: true
|
|
132
|
+
|
|
133
|
+
require "test_helper"
|
|
134
|
+
require "tmpdir"
|
|
135
|
+
|
|
136
|
+
class FetcherTest < Minitest::Test
|
|
137
|
+
FakeResponse = Struct.new(:code, :body)
|
|
138
|
+
|
|
139
|
+
class FakeHttp
|
|
140
|
+
attr_reader :requests
|
|
141
|
+
|
|
142
|
+
def initialize(code, body)
|
|
143
|
+
@code = code
|
|
144
|
+
@body = body
|
|
145
|
+
@requests = []
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get_response(uri)
|
|
149
|
+
@requests << uri
|
|
150
|
+
FakeResponse.new(@code, @body)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def setup
|
|
155
|
+
@dir = Dir.mktmpdir
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def teardown
|
|
159
|
+
FileUtils.remove_entry(@dir)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def test_fetch_downloads_and_writes_file
|
|
163
|
+
body = %({"prefix":"bi","icons":{"search":{"body":"<path/>"}}})
|
|
164
|
+
http = FakeHttp.new("200", body)
|
|
165
|
+
fetcher = SvgIcon::Fetcher.new(http: http)
|
|
166
|
+
destination = File.join(@dir, "bi.json")
|
|
167
|
+
|
|
168
|
+
assert fetcher.fetch("bi", destination)
|
|
169
|
+
assert_equal body, File.read(destination)
|
|
170
|
+
assert_equal "https://raw.githubusercontent.com/iconify/icon-sets/master/json/bi.json", http.requests.first.to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def test_fetch_creates_destination_directory
|
|
174
|
+
http = FakeHttp.new("200", %({"icons":{"x":{"body":"1"}}}))
|
|
175
|
+
destination = File.join(@dir, "nested", "bi.json")
|
|
176
|
+
|
|
177
|
+
SvgIcon::Fetcher.new(http: http).fetch("bi", destination)
|
|
178
|
+
|
|
179
|
+
assert_equal %({"icons":{"x":{"body":"1"}}}), File.read(destination)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def test_fetch_uses_custom_base_url
|
|
183
|
+
http = FakeHttp.new("200", %({"icons":{"x":{"body":"1"}}}))
|
|
184
|
+
SvgIcon::Fetcher.new(base_url: "https://example.com/sets", http: http).fetch("bi", File.join(@dir, "out.json"))
|
|
185
|
+
|
|
186
|
+
assert_equal "https://example.com/sets/bi.json", http.requests.first.to_s
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def test_fetch_raises_on_http_error
|
|
190
|
+
http = FakeHttp.new("404", "Not Found")
|
|
191
|
+
error = assert_raises(SvgIcon::FetchError) do
|
|
192
|
+
SvgIcon::Fetcher.new(http: http).fetch("bi", File.join(@dir, "bi.json"))
|
|
193
|
+
end
|
|
194
|
+
assert_match(/HTTP 404/, error.message)
|
|
195
|
+
assert_match(/not found/, error.message)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def test_fetch_raises_on_invalid_json
|
|
199
|
+
http = FakeHttp.new("200", "not-json")
|
|
200
|
+
error = assert_raises(SvgIcon::FetchError) do
|
|
201
|
+
SvgIcon::Fetcher.new(http: http).fetch("bi", File.join(@dir, "bi.json"))
|
|
202
|
+
end
|
|
203
|
+
assert_match(/invalid JSON/, error.message)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def test_fetch_raises_when_icons_key_missing
|
|
207
|
+
http = FakeHttp.new("200", %({"prefix":"bi"}))
|
|
208
|
+
error = assert_raises(SvgIcon::FetchError) do
|
|
209
|
+
SvgIcon::Fetcher.new(http: http).fetch("bi", File.join(@dir, "bi.json"))
|
|
210
|
+
end
|
|
211
|
+
assert_match(/must contain an 'icons' object/, error.message)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def test_failed_fetch_leaves_no_files
|
|
215
|
+
http = FakeHttp.new("404", "Not Found")
|
|
216
|
+
destination = File.join(@dir, "bi.json")
|
|
217
|
+
|
|
218
|
+
assert_raises(SvgIcon::FetchError) { SvgIcon::Fetcher.new(http: http).fetch("bi", destination) }
|
|
219
|
+
|
|
220
|
+
refute File.exist?(destination)
|
|
221
|
+
assert_empty Dir.glob(File.join(@dir, "*.tmp*"))
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
227
|
+
|
|
228
|
+
Run: `bundle exec ruby -Ilib -Itest test/fetcher_test.rb`
|
|
229
|
+
Expected: all FAIL with `NameError: uninitialized constant SvgIcon::Fetcher`.
|
|
230
|
+
|
|
231
|
+
- [ ] **Step 3: Implement Fetcher**
|
|
232
|
+
|
|
233
|
+
Create `lib/svg_icon/fetcher.rb`:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# frozen_string_literal: true
|
|
237
|
+
|
|
238
|
+
require "net/http"
|
|
239
|
+
require "tempfile"
|
|
240
|
+
|
|
241
|
+
module SvgIcon
|
|
242
|
+
class FetchError < Error; end
|
|
243
|
+
|
|
244
|
+
class Fetcher
|
|
245
|
+
DEFAULT_BASE_URL = "https://raw.githubusercontent.com/iconify/icon-sets/master/json"
|
|
246
|
+
|
|
247
|
+
def initialize(base_url: DEFAULT_BASE_URL, http: Net::HTTP)
|
|
248
|
+
@base_url = base_url
|
|
249
|
+
@http = http
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def fetch(name, destination)
|
|
253
|
+
response = @http.get_response(uri_for(name))
|
|
254
|
+
raise FetchError, "Failed to fetch #{name}: HTTP #{response.code}#{not_found_hint(response.code)}" unless response.code == "200"
|
|
255
|
+
|
|
256
|
+
parse(response.body, name)
|
|
257
|
+
write(response.body, destination)
|
|
258
|
+
true
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
def uri_for(name)
|
|
264
|
+
URI.join("#{@base_url}/", "#{name}.json")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def not_found_hint(code)
|
|
268
|
+
code == "404" ? " (icon set not found)" : ""
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def parse(body, name)
|
|
272
|
+
data = MultiJson.load(body)
|
|
273
|
+
return if data.is_a?(Hash) && data["icons"].is_a?(Hash)
|
|
274
|
+
|
|
275
|
+
raise FetchError, "Invalid icon set '#{name}': JSON must contain an 'icons' object"
|
|
276
|
+
rescue MultiJson::ParseError => e
|
|
277
|
+
raise FetchError, "Invalid icon set '#{name}': invalid JSON (#{e.message})"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def write(body, destination)
|
|
281
|
+
dir = File.dirname(destination)
|
|
282
|
+
FileUtils.mkdir_p(dir)
|
|
283
|
+
temp = Tempfile.new([".#{File.basename(destination)}", ".tmp"], dir)
|
|
284
|
+
begin
|
|
285
|
+
temp.write(body)
|
|
286
|
+
temp.flush
|
|
287
|
+
File.rename(temp.path, destination)
|
|
288
|
+
ensure
|
|
289
|
+
temp.close!
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
In `lib/svg_icon.rb`, add `require_relative "svg_icon/fetcher"` after the `require_relative "svg_icon/helper"` line.
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
299
|
+
|
|
300
|
+
Run: `bundle exec ruby -Ilib -Itest test/fetcher_test.rb`
|
|
301
|
+
Expected: all 7 tests PASS.
|
|
302
|
+
|
|
303
|
+
- [ ] **Step 5: Commit**
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
git add lib/svg_icon.rb lib/svg_icon/fetcher.rb test/fetcher_test.rb
|
|
307
|
+
git commit -m "feat: add Fetcher to download icon sets"
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
### Task 3: `SvgIcon::CLI` + `exe/svg_icon`
|
|
313
|
+
|
|
314
|
+
**Files:**
|
|
315
|
+
- Create: `lib/svg_icon/cli.rb`
|
|
316
|
+
- Create: `exe/svg_icon`
|
|
317
|
+
- Create: `test/cli_test.rb`
|
|
318
|
+
|
|
319
|
+
- [ ] **Step 1: Write the failing tests**
|
|
320
|
+
|
|
321
|
+
Create `test/cli_test.rb`:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
# frozen_string_literal: true
|
|
325
|
+
|
|
326
|
+
require "test_helper"
|
|
327
|
+
|
|
328
|
+
class CliTest < Minitest::Test
|
|
329
|
+
FakeFetcher = Struct.new(:name, :destination) do
|
|
330
|
+
def fetch(name, destination)
|
|
331
|
+
self.name = name
|
|
332
|
+
self.destination = destination
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def test_fetch_downloads_into_icons_path
|
|
337
|
+
fake = FakeFetcher.new
|
|
338
|
+
SvgIcon::Fetcher.stub(:new, fake) do
|
|
339
|
+
out, = capture_io { SvgIcon::CLI.run(["fetch", "bi"]) }
|
|
340
|
+
assert_equal "bi", fake.name
|
|
341
|
+
assert_equal File.join(SvgIcon.configuration.icons_path, "bi.json"), fake.destination
|
|
342
|
+
assert_includes out, "Saved to"
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def test_fetch_without_name_exits_with_usage
|
|
347
|
+
error = assert_raises(SystemExit) do
|
|
348
|
+
capture_io { SvgIcon::CLI.run(["fetch"]) }
|
|
349
|
+
end
|
|
350
|
+
assert_equal 1, error.status
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def test_fetch_with_invalid_name_exits
|
|
354
|
+
error = assert_raises(SystemExit) do
|
|
355
|
+
capture_io { SvgIcon::CLI.run(["fetch", "../evil"]) }
|
|
356
|
+
end
|
|
357
|
+
assert_equal 1, error.status
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def test_fetch_with_extra_arguments_exits
|
|
361
|
+
error = assert_raises(SystemExit) do
|
|
362
|
+
capture_io { SvgIcon::CLI.run(["fetch", "bi", "extra"]) }
|
|
363
|
+
end
|
|
364
|
+
assert_equal 1, error.status
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def test_fetch_propagates_fetch_error
|
|
368
|
+
SvgIcon::Fetcher.stub(:new, proc { |_base_url, _http| raise SvgIcon::FetchError, "boom" }) do
|
|
369
|
+
_, err = capture_io do
|
|
370
|
+
error = assert_raises(SystemExit) { SvgIcon::CLI.run(["fetch", "bi"]) }
|
|
371
|
+
assert_equal 1, error.status
|
|
372
|
+
end
|
|
373
|
+
assert_includes err, "boom"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def test_unknown_command_exits
|
|
378
|
+
error = assert_raises(SystemExit) do
|
|
379
|
+
capture_io { SvgIcon::CLI.run(["frobnicate"]) }
|
|
380
|
+
end
|
|
381
|
+
assert_equal 1, error.status
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def test_help_prints_usage_without_error
|
|
385
|
+
out, = capture_io { SvgIcon::CLI.run(["help"]) }
|
|
386
|
+
assert_includes out, "Usage:"
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Note: `SvgIcon::Fetcher.stub(:new, fake)` stubs `SvgIcon::Fetcher.new`; the CLI must call `SvgIcon::Fetcher.new` (with default args) so the stub intercepts it. The `proc` stub in `test_fetch_propagates_fetch_error` works because minitest calls the stub object's `call` when the stubbed method is invoked with matching args — the proc receives the same args `new` was called with.
|
|
392
|
+
|
|
393
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
394
|
+
|
|
395
|
+
Run: `bundle exec ruby -Ilib -Itest test/cli_test.rb`
|
|
396
|
+
Expected: FAIL with `NameError: uninitialized constant SvgIcon::CLI`.
|
|
397
|
+
|
|
398
|
+
- [ ] **Step 3: Implement CLI**
|
|
399
|
+
|
|
400
|
+
Create `lib/svg_icon/cli.rb`:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
# frozen_string_literal: true
|
|
404
|
+
|
|
405
|
+
module SvgIcon
|
|
406
|
+
class CLI
|
|
407
|
+
def self.run(argv)
|
|
408
|
+
new(argv).run
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def initialize(argv)
|
|
412
|
+
@argv = argv
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def run
|
|
416
|
+
command = @argv.shift
|
|
417
|
+
case command
|
|
418
|
+
when "fetch"
|
|
419
|
+
fetch
|
|
420
|
+
when nil, "help", "--help", "-h"
|
|
421
|
+
puts usage
|
|
422
|
+
else
|
|
423
|
+
warn "Unknown command: #{command}"
|
|
424
|
+
warn usage
|
|
425
|
+
exit 1
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
private
|
|
430
|
+
|
|
431
|
+
def fetch
|
|
432
|
+
name = @argv.shift
|
|
433
|
+
unless valid_name?(name) && @argv.empty?
|
|
434
|
+
warn "Usage: svg_icon fetch <icon_set_name>"
|
|
435
|
+
exit 1
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
destination = File.join(SvgIcon.configuration.icons_path, "#{name}.json")
|
|
439
|
+
puts "Fetching #{name} from iconify/icon-sets"
|
|
440
|
+
SvgIcon::Fetcher.new.fetch(name, destination)
|
|
441
|
+
puts "Saved to #{relative_path(destination)}"
|
|
442
|
+
rescue SvgIcon::FetchError => e
|
|
443
|
+
warn e.message
|
|
444
|
+
exit 1
|
|
445
|
+
rescue StandardError => e
|
|
446
|
+
warn "Error: #{e.message}"
|
|
447
|
+
exit 1
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def valid_name?(name)
|
|
451
|
+
!name.nil? && !name.empty? && name.match?(/\A[a-z0-9\-_]+\z/)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def relative_path(path)
|
|
455
|
+
path.sub("#{Dir.pwd}/", "")
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def usage
|
|
459
|
+
<<~TEXT
|
|
460
|
+
Usage: svg_icon COMMAND
|
|
461
|
+
|
|
462
|
+
Commands:
|
|
463
|
+
fetch NAME Download icon set NAME (e.g. bi) from iconify/icon-sets into #{SvgIcon.configuration.icons_path}
|
|
464
|
+
help Show this help
|
|
465
|
+
TEXT
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Create `exe/svg_icon` (make executable):
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
#!/usr/bin/env ruby
|
|
475
|
+
# frozen_string_literal: true
|
|
476
|
+
|
|
477
|
+
require "svg_icon"
|
|
478
|
+
require "svg_icon/cli"
|
|
479
|
+
|
|
480
|
+
exit(SvgIcon::CLI.run(ARGV) || 0)
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
In `lib/svg_icon.rb`, add `require_relative "svg_icon/cli"` after `require_relative "svg_icon/fetcher"`.
|
|
484
|
+
|
|
485
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
486
|
+
|
|
487
|
+
Run: `bundle exec ruby -Ilib -Itest test/cli_test.rb`
|
|
488
|
+
Expected: all 7 tests PASS.
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 5: Verify the executable works end-to-end**
|
|
491
|
+
|
|
492
|
+
Run: `chmod +x exe/svg_icon && bundle exec ruby exe/svg_icon fetch nonexistent-set-xyz && echo "UNEXPECTED SUCCESS" || echo "expected failure"`
|
|
493
|
+
Expected: `expected failure` (network 404 or resolve failure → non-zero exit). Then run `bundle exec ruby exe/svg_icon help` — expect usage text.
|
|
494
|
+
|
|
495
|
+
- [ ] **Step 6: Commit**
|
|
496
|
+
|
|
497
|
+
```bash
|
|
498
|
+
git add lib/svg_icon.rb lib/svg_icon/cli.rb exe/svg_icon test/cli_test.rb
|
|
499
|
+
git commit -m "feat: add svg_icon fetch CLI"
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
---
|
|
503
|
+
|
|
504
|
+
### Task 4: README + full verification
|
|
505
|
+
|
|
506
|
+
**Files:**
|
|
507
|
+
- Modify: `README.md`
|
|
508
|
+
- Test: all existing tests
|
|
509
|
+
|
|
510
|
+
- [ ] **Step 1: Update README**
|
|
511
|
+
|
|
512
|
+
Replace the `## Usage` section's initializer example block (the `SvgIcon.configure` code block) with:
|
|
513
|
+
|
|
514
|
+
```ruby
|
|
515
|
+
SvgIcon.configure do |config|
|
|
516
|
+
# config.icon = "lucide" # icon set name: "lucide", "bi", "bx", "heroicons", or any fetched set
|
|
517
|
+
config.icons_path = Rails.root.join("config", "svg_icons") # defaults to "config/svg_icons" under the project root
|
|
518
|
+
|
|
519
|
+
##
|
|
520
|
+
# You can set a default class for icon
|
|
521
|
+
config.default_class = ""
|
|
522
|
+
end
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
After the `<%= svg_icon("search") %>` usage example, add:
|
|
526
|
+
|
|
527
|
+
```markdown
|
|
528
|
+
## Fetching icon sets
|
|
529
|
+
|
|
530
|
+
The gem bundles a few icon sets (lucide, bi, bx, heroicons). To use any other
|
|
531
|
+
[iconify icon set](https://github.com/iconify/icon-sets/tree/master/json), download it into your project:
|
|
532
|
+
|
|
533
|
+
$ svg_icon fetch bi
|
|
534
|
+
|
|
535
|
+
This downloads `bi.json` into `config/svg_icons/`. Set `config.icon = "bi"` to use it —
|
|
536
|
+
the gem looks for `<icon>.json` in `config/svg_icons/` first, then falls back to bundled data.
|
|
537
|
+
|
|
538
|
+
Commit `config/svg_icons/` to your repository so deploys don't need to re-fetch.
|
|
539
|
+
Re-run `svg_icon fetch <name>` to update an existing set.
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
- [ ] **Step 2: Run full test suite**
|
|
543
|
+
|
|
544
|
+
Run: `bundle exec rake test`
|
|
545
|
+
Expected: 40 runs, 0 failures, 0 errors (22 existing + 4 + 7 + 7 new).
|
|
546
|
+
|
|
547
|
+
- [ ] **Step 3: Verify gem packaging includes the executable**
|
|
548
|
+
|
|
549
|
+
Run: `gem build svg_icon.gemspec && tar -tf svg_icon-*.gem > /dev/null; rm svg_icon-*.gem`
|
|
550
|
+
Expected: build succeeds. The new `exe/svg_icon` is tracked by git (committed in Task 3), so `git ls-files` includes it and the gem ships it.
|
|
551
|
+
|
|
552
|
+
- [ ] **Step 4: Commit**
|
|
553
|
+
|
|
554
|
+
```bash
|
|
555
|
+
git add README.md
|
|
556
|
+
git commit -m "docs: document svg_icon fetch CLI"
|
|
557
|
+
```
|