svg_icon 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11778daaa40d8cb36e0a8637b59b96fbbe5e6b266bef88aa7d5aa5b5937df6c1
4
- data.tar.gz: f939de88a632a7d8767f11ae14dde71c12d8eb59c026f7dc615b0248e7d47cbd
3
+ metadata.gz: b388bb80ccfff538510163078ff382d07908549abaa76da0382af3ee7bc1ed07
4
+ data.tar.gz: b646054bc7c5035c49f51dd4e6845154aba0390d0d37d5804462e0869ef74ebf
5
5
  SHA512:
6
- metadata.gz: 5a115c8d3f6b37e3c21fdbb5f43dc003eeb085d4e3f578b155bc592d98d24208abcee66e14e10b7842aa7223fbe9ddec7594463765033b382285b5c15f3f4577
7
- data.tar.gz: '090f3b45bf7250d42ec97527027420ed04c9a2baf5077c33b72e3ee9d3de22d55281a6d8dd89c83a9e864d68fd3193fa1342c4ac0d5fbd4f2790d701c9847b27'
6
+ metadata.gz: 88cad87bd6dbad2faf9ac6458f9f0b30cb0e851df11eaac75034b0b85bd068a0cd820b4ea41d860f012ea697d923f3c6f10fdf44d6df5274cea4adaff46c5686
7
+ data.tar.gz: 527479eb7d5a90b3a10d4473298db0d166acefac938d4f227c40fc76e49872be8980f44f7c24cff4f9cc0017cc1fc67c53c149e574afcc84575dc3358cb0b4c1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- svg_icon (0.1.0)
4
+ svg_icon (0.4.0)
5
5
  activesupport (>= 5.0)
6
6
  multi_json
7
7
 
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 = "bi" # Options are "bi" and "bx"
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.
@@ -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
+ ```
@@ -0,0 +1,100 @@
1
+ # svg_icon fetch CLI — 设计文档
2
+
3
+ - 日期:2026-08-18
4
+ - 状态:已批准
5
+
6
+ ## 背景
7
+
8
+ gem 内置的 icon set json(bi/bx/lucide/heroicons)来自 [iconify/icon-sets](https://github.com/iconify/icon-sets/tree/master/json)。把所有 icon set 打包进 gem 不现实。需要一个 CLI 命令,让用户从 iconify/icon-sets 抓取任意 icon set 到项目本地,并通过配置使用。
9
+
10
+ ## 目标
11
+
12
+ - `svg_icon fetch <name>` 从 iconify/icon-sets 下载 `<name>.json` 到项目 `config/svg_icons/` 目录
13
+ - 下载的 json 作为独立 icon set 使用:`config.icon = "<name>"` 时优先加载它,与内置 lucide 等平级
14
+ - 零新增运行时依赖(标准库 `net/http` + 现有 `multi_json`)
15
+ - 下载后离线可用,建议提交进版本控制
16
+
17
+ ## 架构
18
+
19
+ ### 1. CLI(`exe/svg_icon`)
20
+
21
+ ```
22
+ $ svg_icon fetch bi
23
+ Fetching https://raw.githubusercontent.com/iconify/icon-sets/master/json/bi.json
24
+ Saved to config/svg_icons/bi.json
25
+ ```
26
+
27
+ - 使用 `OptionParser` 做子命令分发
28
+ - `fetch <name>` 行为:
29
+ - 下载 URL:`https://raw.githubusercontent.com/iconify/icon-sets/master/json/<name>.json`
30
+ - 目标:`<icons_path>/<name>.json`,目录不存在时自动创建
31
+ - 先写临时文件,校验成功后 rename 到目标;失败不留下半截文件
32
+ - 目标已存在则覆盖(作为更新手段)
33
+ - 校验规则:JSON 可解析,且是 Hash 且含 `icons`(Hash)键
34
+ - 错误处理(全部 stderr + exit 1):
35
+ - 网络错误 / HTTP 非 200 → `FetchError`
36
+ - JSON 无效或不含 `icons` → `FetchError`
37
+ - 缺少子命令或参数 → usage 提示,exit 1
38
+
39
+ ### 2. 配置扩展(`lib/svg_icon/configuration.rb`)
40
+
41
+ - 新增 `attr_accessor :icons_path`,默认 `File.join(Dir.pwd, "config/svg_icons")`
42
+ - 语义:外部 icon set 的查找目录
43
+
44
+ ### 3. 数据查找(`lib/svg_icon.rb`)
45
+
46
+ `file_data` 查找顺序:
47
+
48
+ 1. 外部 `<icons_path>/<icon>.json`(存在则优先)
49
+ 2. 内置 `lib/data/<icon>.json`
50
+ 3. 都不存在 → 现有 `SvgIcon::Error`("Icon data file not found")
51
+
52
+ 外部优先:用户下载的版本覆盖内置,且内置 bi/bx 等与下载同名时以外部为准(数据更新)。
53
+
54
+ ### 4. Fetcher(`lib/svg_icon/fetcher.rb`)
55
+
56
+ ```ruby
57
+ module SvgIcon
58
+ class FetchError < Error; end
59
+
60
+ class Fetcher
61
+ def initialize(base_url: DEFAULT_BASE_URL, http: Net::HTTP, ...)
62
+ def fetch(name, destination) # destination 是完整目标文件路径(含文件名),由调用方组装
63
+ # 返回 bool/抛出 FetchError
64
+ end
65
+ end
66
+ ```
67
+
68
+ - `base_url` 和 http 客户端可注入,便于测试
69
+ - 下载 → 解析校验 → 写临时文件 → rename
70
+
71
+ ## 错误处理汇总
72
+
73
+ | 场景 | 行为 |
74
+ | --- | --- |
75
+ | 网络错误 / DNS 失败 | `SvgIcon::FetchError`,exit 1 |
76
+ | HTTP 404(icon set 不存在) | `SvgIcon::FetchError`,exit 1 |
77
+ | 返回体不是合法 JSON | `SvgIcon::FetchError`,exit 1 |
78
+ | JSON 不含 `icons` 对象 | `SvgIcon::FetchError`,exit 1 |
79
+ | `icons_path` 目录不可写 | 原样异常,exit 1 |
80
+
81
+ ## 测试(minitest)
82
+
83
+ - **fetcher_test.rb**(`test/fetcher_test.rb`)
84
+ - 成功下载并写入正确文件(mock HTTP)
85
+ - 404 → FetchError
86
+ - 无效 JSON → FetchError
87
+ - 缺 `icons` 键 → FetchError
88
+ - 失败时不留下临时/半截文件
89
+ - **查找顺序**(`test/svg_icon_test.rb` 扩展)
90
+ - `icons_path` 存在同名文件 → 加载外部
91
+ - 外部没有 → 回退内置
92
+ - 都没有 → `SvgIcon::Error`
93
+ - **CLI 集成**(`test/cli_test.rb`)
94
+ - 在临时目录跑 `svg_icon fetch <name>`,断言 exit 0、文件写入
95
+ - 无效参数 → exit 1
96
+
97
+ ## README 更新
98
+
99
+ - 新增 "Fetching icon sets" 章节:安装、`svg_icon fetch bi` 用法、`config.icon` / `config.icons_path` 示例
100
+ - 建议把 `config/svg_icons/` 提交进版本控制,部署无需重新抓取
data/exe/svg_icon ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "svg_icon"
5
+ require "svg_icon/cli"
6
+
7
+ exit(SvgIcon::CLI.run(ARGV) || 0)
@@ -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
@@ -7,9 +7,11 @@ module SvgIcon
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
 
@@ -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,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SvgIcon
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/svg_icon.rb CHANGED
@@ -11,11 +11,14 @@ require "active_support/core_ext/string/output_safety"
11
11
  module SvgIcon
12
12
  class Error < StandardError; end
13
13
 
14
+ require_relative "svg_icon/fetcher"
15
+ require_relative "svg_icon/cli"
16
+
14
17
  extend self
15
18
 
16
19
  def icons
17
20
  @icons ||= {}
18
- @icons[icon] ||= MultiJson.load(file_data)
21
+ @icons[file_cache_key_for(resolve_icon_path)] ||= MultiJson.load(file_data)
19
22
  end
20
23
 
21
24
  def icons_json
@@ -41,25 +44,34 @@ module SvgIcon
41
44
  SvgIcon.configuration
42
45
  end
43
46
 
44
- def data_path
45
- File.join(__dir__, "data", "#{icon}.json")
46
- end
47
-
48
47
  def file_data
49
48
  @file_data ||= {}
50
- @file_data[data_path] ||= begin
51
- path = data_path
52
- raise Error, "Icon data file not found: #{path}" unless File.exist?(path)
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)
53
52
 
54
53
  File.read(path)
55
54
  end
56
55
  end
57
56
 
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")
62
+ end
63
+
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
+
58
70
  def extra_icons
59
71
  return {} unless configuration.extra_icons_path
60
72
 
61
73
  @extra_icons ||= {}
62
- @extra_icons[extra_path] ||= begin
74
+ @extra_icons[file_cache_key_for(extra_path)] ||= begin
63
75
  data = MultiJson.load(extra_file_data)
64
76
  raise Error, "Extra icons file must contain a JSON object: #{extra_path}" unless data.is_a?(Hash)
65
77
 
@@ -73,7 +85,7 @@ module SvgIcon
73
85
 
74
86
  def extra_file_data
75
87
  @extra_file_data ||= {}
76
- @extra_file_data[extra_path] ||= begin
88
+ @extra_file_data[file_cache_key_for(extra_path)] ||= begin
77
89
  path = extra_path
78
90
  raise Error, "Extra icons file not found: #{path}" unless File.exist?(path)
79
91
 
@@ -83,7 +95,7 @@ module SvgIcon
83
95
 
84
96
  def merge_extra_icons(base_icons)
85
97
  icons_set = base_icons["icons"]
86
- raise Error, "Icon data must contain an 'icons' object: #{data_path}" unless icons_set.is_a?(Hash)
98
+ raise Error, "Icon data must contain an 'icons' object: #{resolve_icon_path}" unless icons_set.is_a?(Hash)
87
99
 
88
100
  return base_icons if extra_icons.empty?
89
101
 
@@ -93,6 +105,6 @@ module SvgIcon
93
105
  end
94
106
 
95
107
  def cache_key
96
- "#{icon}:#{extra_path}"
108
+ "#{icon}:#{file_cache_key_for(resolve_icon_path)}:#{file_cache_key_for(extra_path)}"
97
109
  end
98
110
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: svg_icon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - doabit
@@ -54,7 +54,8 @@ dependencies:
54
54
  description: Svg icon render helper for rails.
55
55
  email:
56
56
  - doinsist@gmail.com
57
- executables: []
57
+ executables:
58
+ - svg_icon
58
59
  extensions: []
59
60
  extra_rdoc_files: []
60
61
  files:
@@ -65,12 +66,17 @@ files:
65
66
  - Rakefile
66
67
  - bin/console
67
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
68
72
  - lib/data/bi.json
69
73
  - lib/data/bx.json
70
74
  - lib/data/heroicons.json
71
75
  - lib/data/lucide.json
72
76
  - lib/svg_icon.rb
77
+ - lib/svg_icon/cli.rb
73
78
  - lib/svg_icon/configuration.rb
79
+ - lib/svg_icon/fetcher.rb
74
80
  - lib/svg_icon/helper.rb
75
81
  - lib/svg_icon/version.rb
76
82
  - svg_icon.gemspec