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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +16 -4
- 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/svg_icon/cli.rb +68 -0
- data/lib/svg_icon/configuration.rb +2 -0
- data/lib/svg_icon/fetcher.rb +73 -0
- data/lib/svg_icon/version.rb +1 -1
- data/lib/svg_icon.rb +24 -12
- metadata +8 -2
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
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.
|
|
@@ -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
data/lib/svg_icon/cli.rb
ADDED
|
@@ -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
|
|
@@ -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
|
data/lib/svg_icon/version.rb
CHANGED
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[
|
|
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[
|
|
51
|
-
path =
|
|
52
|
-
raise Error, "Icon data file not found: #{
|
|
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: #{
|
|
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.
|
|
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
|