unmagic-icon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Icon
5
+ class Library
6
+ class Source
7
+ class Tabler < Source
8
+ key :tabler
9
+ title "Tabler Icons"
10
+ description "Over 5400 free SVG icons"
11
+ url "https://github.com/tabler/tabler-icons/releases/download/v3.24.0/tabler-icons-3.24.0.zip"
12
+ archive :zip
13
+ extract "svg/*.svg"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "pathname"
7
+ require "fileutils"
8
+ require "tmpdir"
9
+ require "open3"
10
+
11
+ # Terminal progress is a nicety, not a requirement. The downloader runs fine
12
+ # (with plain output) when these sibling gems aren't installed.
13
+ begin
14
+ require "unmagic/terminal/progress_bar"
15
+ require "unmagic/support/monitored_enumerator"
16
+ rescue LoadError
17
+ # no-op — falls back to plain iteration in #each_with_progress
18
+ end
19
+
20
+ module Unmagic
21
+ class Icon
22
+ class Library
23
+ # A downloadable source for an icon library: its metadata and how to fetch
24
+ # it. Each library is a subclass that declares metadata with the class-level
25
+ # DSL; the base carries the shared download/extract/manifest plumbing. A
26
+ # subclass overrides #write_manifest to generate a mapping, or #acquire to
27
+ # use a different transport.
28
+ class Source
29
+ class Error < StandardError; end
30
+ class DownloadError < Error; end
31
+ class ExtractionError < Error; end
32
+
33
+ REGISTRY = {}
34
+
35
+ class << self
36
+ def all
37
+ REGISTRY.values
38
+ end
39
+
40
+ def find(name)
41
+ REGISTRY[name.to_sym] or raise ArgumentError, "Unknown library: #{name}"
42
+ end
43
+
44
+ def exists?(name)
45
+ REGISTRY.key?(name.to_sym)
46
+ end
47
+
48
+ # ---- metadata DSL (each setter doubles as a reader) ----
49
+
50
+ def key(value = nil)
51
+ return @key unless value
52
+
53
+ @key = value.to_sym
54
+ REGISTRY[@key] = self
55
+ end
56
+
57
+ def title(value = nil)
58
+ value ? @title = value : @title
59
+ end
60
+
61
+ def description(value = nil)
62
+ value ? @description = value : @description
63
+ end
64
+
65
+ def url(value = nil)
66
+ value ? @url = value : @url
67
+ end
68
+
69
+ def archive(value = nil)
70
+ value ? @archive = value : @archive
71
+ end
72
+
73
+ # On-disk directory name; defaults to the key.
74
+ def dir(value = nil)
75
+ value ? @dir = value.to_s : (@dir || key.to_s)
76
+ end
77
+
78
+ # Glob patterns (relative to the extracted archive) of svgs to copy.
79
+ def extract(*patterns)
80
+ patterns.empty? ? (@extract || []) : @extract = patterns
81
+ end
82
+
83
+ # Map source directories to target subdirectories (e.g. heroicons sizes).
84
+ def extract_into(mapping = nil)
85
+ mapping ? @extract_into = mapping : @extract_into
86
+ end
87
+ end
88
+
89
+ def download(target_dir: default_target_dir, force: false)
90
+ target_dir = Pathname(target_dir)
91
+
92
+ if target_dir.exist? && !force
93
+ puts "→ Skipping #{self.class.title} (already exists at #{target_dir}, use force: true to re-download)"
94
+ return
95
+ end
96
+
97
+ puts "→ Downloading #{self.class.title}..."
98
+ puts " #{self.class.description}"
99
+
100
+ acquire(target_dir)
101
+
102
+ puts " ✓ Downloaded #{count_svgs(target_dir)} icons to #{target_dir}"
103
+ end
104
+
105
+ private
106
+
107
+ def default_target_dir
108
+ base = Unmagic::Icon.configuration.download_path
109
+ raise ArgumentError, "Set Unmagic::Icon.configuration.download_path or pass target_dir:" unless base
110
+
111
+ Pathname(base).join(self.class.dir)
112
+ end
113
+
114
+ # Default acquisition: download an archive, extract, copy matched svgs,
115
+ # then let the subclass write a manifest. Override for other transports.
116
+ def acquire(target_dir)
117
+ Dir.mktmpdir do |tmpdir|
118
+ archive_path = File.join(tmpdir, "#{self.class.key}#{File.extname(self.class.url)}")
119
+ download_file(self.class.url, archive_path)
120
+ extract_archive(archive_path, tmpdir, self.class.archive)
121
+
122
+ FileUtils.mkdir_p(target_dir)
123
+ copy_assets(tmpdir, target_dir)
124
+ write_manifest(tmpdir, target_dir)
125
+ end
126
+ end
127
+
128
+ # Libraries that ship a file→icon mapping override this to write a
129
+ # manifest.json into target_dir. Default: nothing.
130
+ def write_manifest(_tmpdir, _target_dir)
131
+ end
132
+
133
+ def copy_assets(tmpdir, target_dir)
134
+ if self.class.extract_into
135
+ copy_into_subdirs(tmpdir, target_dir)
136
+ else
137
+ copy_flat(tmpdir, target_dir)
138
+ end
139
+ end
140
+
141
+ def copy_flat(tmpdir, target_dir)
142
+ files = self.class.extract.flat_map do |pattern|
143
+ Dir.glob(File.join(tmpdir, pattern)).select { |file| File.file?(file) }
144
+ end
145
+ return if files.empty?
146
+
147
+ puts " Extracting #{files.size} icons..."
148
+ each_with_progress(files) { |file| FileUtils.cp(file, target_dir) }
149
+ end
150
+
151
+ def copy_into_subdirs(tmpdir, target_dir)
152
+ files = self.class.extract_into.flat_map do |source_dir, subdir|
153
+ Dir.glob(File.join(tmpdir, source_dir, "*.svg")).map { |file| [ file, subdir ] }
154
+ end
155
+ return if files.empty?
156
+
157
+ puts " Extracting #{files.size} icons..."
158
+ each_with_progress(files) do |(file, subdir)|
159
+ next unless File.file?(file)
160
+
161
+ destination = Pathname(target_dir).join(subdir)
162
+ FileUtils.mkdir_p(destination)
163
+ FileUtils.cp(file, destination)
164
+ end
165
+ end
166
+
167
+ def each_with_progress(items, show_time: false)
168
+ if defined?(Unmagic::Support::MonitoredEnumerator) && defined?(Unmagic::Terminal::ProgressBar)
169
+ bar = Unmagic::Terminal::ProgressBar.new(total: items.size, width: 40, show_time: show_time)
170
+ monitor = proc do |event|
171
+ case event.event
172
+ when :progress
173
+ bar.update(event.progress.current + 1)
174
+ print "\r #{bar.render}"
175
+ when :finish
176
+ puts
177
+ end
178
+ end
179
+
180
+ Unmagic::Support::MonitoredEnumerator.new(items, monitor).each { |item| yield item }
181
+ else
182
+ items.each { |item| yield item }
183
+ end
184
+ end
185
+
186
+ def download_file(url, destination)
187
+ response = fetch_with_redirect(URI(url))
188
+ raise DownloadError, "Failed to download #{url}: HTTP #{response.code}" if response.code != "200"
189
+
190
+ File.binwrite(destination, response.body)
191
+ end
192
+
193
+ def fetch_with_redirect(uri, headers = {}, limit = 10)
194
+ raise DownloadError, "Too many redirects" if limit.zero?
195
+
196
+ http = Net::HTTP.new(uri.host, uri.port)
197
+ http.use_ssl = (uri.scheme == "https")
198
+
199
+ request = Net::HTTP::Get.new(uri)
200
+ headers.each { |name, value| request[name] = value }
201
+
202
+ response = http.request(request)
203
+
204
+ case response
205
+ when Net::HTTPSuccess
206
+ response
207
+ when Net::HTTPRedirection
208
+ fetch_with_redirect(URI(response["location"]), headers, limit - 1)
209
+ else
210
+ response
211
+ end
212
+ end
213
+
214
+ def extract_archive(archive_path, destination, type)
215
+ case type
216
+ when :zip
217
+ _, stderr, status = Open3.capture3("unzip", "-q", "-o", archive_path, "-d", destination)
218
+ raise ExtractionError, "Failed to extract zip: #{stderr}" unless status.success?
219
+ when :tgz, :tar
220
+ _, stderr, status = Open3.capture3("tar", "-xzf", archive_path, "-C", destination)
221
+ raise ExtractionError, "Failed to extract tar: #{stderr}" unless status.success?
222
+ else
223
+ raise ExtractionError, "Unknown archive type: #{type}"
224
+ end
225
+ end
226
+
227
+ def count_svgs(directory)
228
+ Dir.glob(File.join(directory, "**", "*.svg")).size
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ require_relative "source/heroicons"
236
+ require_relative "source/devicons"
237
+ require_relative "source/feather"
238
+ require_relative "source/tabler"
239
+ require_relative "source/lucide"
240
+ require_relative "source/simple_icons"
241
+ require_relative "source/material_file_icons"
242
+ require_relative "source/silk"
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Unmagic
6
+ class Icon
7
+ class Library
8
+ # The optional per-set manifest. Asset-kind-agnostic: it carries metadata,
9
+ # a `default`, a global `aliases` map, and optional per-entry declarations
10
+ # (`entries`, each with its own `aliases`). Icons auto-discover from disk,
11
+ # so for an icon library only `default` + `aliases` are typically present.
12
+ MANIFEST_FILENAME = "manifest.json"
13
+
14
+ # The lone asset-kind-specific bit: which files in the directory are assets.
15
+ # SVG today; a future raster pack would scan png/gif here.
16
+ ASSET_GLOB = "*.svg"
17
+
18
+ attr_reader :name, :path
19
+
20
+ def initialize(name:, path:)
21
+ @name = name
22
+ @path = path
23
+ end
24
+
25
+ # Resolve a query to an icon. Order: exact icon file, then the alias index
26
+ # (exact alias, then longest matching glob/pattern), then the manifest's
27
+ # default. Raises when nothing matches and no default is declared.
28
+ def find(query)
29
+ resolve(query.to_s) or
30
+ raise Unmagic::Icon::IconNotFoundError.new("Can't find #{query} in #{path}")
31
+ end
32
+
33
+ def icons
34
+ icons_by_key.values
35
+ end
36
+
37
+ def aliases
38
+ alias_index
39
+ end
40
+
41
+ private
42
+
43
+ def resolve(query)
44
+ icons_by_key[query] ||
45
+ aliased(query) ||
46
+ default_icon
47
+ end
48
+
49
+ def aliased(query)
50
+ target = lookup_alias(query.downcase)
51
+ icons_by_key[target] if target
52
+ end
53
+
54
+ # Exact aliases win over patterns; patterns are tried longest-first so the
55
+ # most specific match wins (e.g. "*.test.tsx" beats "*.tsx").
56
+ def lookup_alias(key)
57
+ exact, patterns = alias_index
58
+ exact[key] ||
59
+ patterns.find { |pattern, _| File.fnmatch?(pattern, key, File::FNM_EXTGLOB) }&.last
60
+ end
61
+
62
+ def default_icon
63
+ target = manifest["default"]
64
+ icons_by_key[target] if target
65
+ end
66
+
67
+ def icons_by_key
68
+ @icons_by_key ||= Dir.glob(File.join(@path, ASSET_GLOB)).to_h do |icon_path|
69
+ icon_key = File.basename(icon_path, ".svg")
70
+ [ icon_key, Unmagic::Icon.new(name: "#{@name}/#{icon_key}", path: icon_path) ]
71
+ end
72
+ end
73
+
74
+ # The whole alias map, normalized to downcased keys and split into exact
75
+ # strings vs glob patterns. Built from the manifest's global `aliases` plus
76
+ # each entry's per-entry `aliases` (the emoji-shaped shortcuts case).
77
+ def alias_index
78
+ @alias_index ||=
79
+ begin
80
+ exact = {}
81
+ patterns = {}
82
+
83
+ manifest_aliases.each do |key, target|
84
+ normalized = key.to_s.downcase
85
+ if normalized.match?(/[*?\[{]/)
86
+ patterns[normalized] = target
87
+ else
88
+ exact[normalized] = target
89
+ end
90
+ end
91
+
92
+ [ exact, patterns.sort_by { |pattern, _| -pattern.length }.to_h ]
93
+ end
94
+ end
95
+
96
+ def manifest_aliases
97
+ global = manifest["aliases"] || {}
98
+ per_entry = Array(manifest["entries"]).each_with_object({}) do |entry, mapped|
99
+ Array(entry["aliases"]).each { |key| mapped[key] = entry["name"] }
100
+ end
101
+ global.merge(per_entry)
102
+ end
103
+
104
+ def manifest
105
+ @manifest ||= load_manifest
106
+ end
107
+
108
+ def load_manifest
109
+ file = File.join(@path, MANIFEST_FILENAME)
110
+ File.exist?(file) ? JSON.parse(File.read(file)) : {}
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Icon
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,329 @@
1
+ <title>Unmagic Icon Gallery</title>
2
+ <style>
3
+ * { margin: 0; padding: 0; box-sizing: border-box; }
4
+
5
+ :root {
6
+ --bg: #fff;
7
+ --fg: #333;
8
+ --border: #e0e0e0;
9
+ --tab-bg: #f5f5f5;
10
+ --tab-hover: #e8e8e8;
11
+ --tab-border: #ddd;
12
+ --active-bg: #333;
13
+ --active-fg: #fff;
14
+ --icon-hover: #f8f8f8;
15
+ --icon-border-hover: #999;
16
+ --input-border: #ccc;
17
+ --input-border-focus: #666;
18
+ --muted: #666;
19
+ --no-results: #999;
20
+ }
21
+
22
+ [data-theme="dark"] {
23
+ --bg: #1a1a1a;
24
+ --fg: #e0e0e0;
25
+ --border: #333;
26
+ --tab-bg: #2a2a2a;
27
+ --tab-hover: #333;
28
+ --tab-border: #444;
29
+ --active-bg: #e0e0e0;
30
+ --active-fg: #1a1a1a;
31
+ --icon-hover: #2a2a2a;
32
+ --icon-border-hover: #666;
33
+ --input-border: #444;
34
+ --input-border-focus: #888;
35
+ --muted: #999;
36
+ --no-results: #666;
37
+ }
38
+
39
+ body {
40
+ font-family: -apple-system, system-ui, sans-serif;
41
+ background: var(--bg);
42
+ color: var(--fg);
43
+ padding: 20px;
44
+ line-height: 1.5;
45
+ }
46
+
47
+ .header {
48
+ margin-bottom: 30px;
49
+ padding-bottom: 20px;
50
+ border-bottom: 1px solid var(--border);
51
+ position: relative;
52
+ }
53
+
54
+ .theme-switcher {
55
+ position: absolute;
56
+ top: 0;
57
+ right: 0;
58
+ padding: 8px 12px;
59
+ background: var(--tab-bg);
60
+ border: 1px solid var(--tab-border);
61
+ border-radius: 4px;
62
+ cursor: pointer;
63
+ font-size: 14px;
64
+ }
65
+
66
+ .theme-switcher:hover {
67
+ background: var(--tab-hover);
68
+ }
69
+
70
+ .search-box {
71
+ margin-bottom: 20px;
72
+ }
73
+
74
+ .search-box input {
75
+ width: 100%;
76
+ max-width: 400px;
77
+ padding: 8px 12px;
78
+ font-size: 14px;
79
+ border: 1px solid var(--input-border);
80
+ border-radius: 4px;
81
+ outline: none;
82
+ }
83
+
84
+ .search-box input:focus {
85
+ border-color: var(--input-border-focus);
86
+ }
87
+
88
+ .tabs {
89
+ display: flex;
90
+ gap: 10px;
91
+ flex-wrap: wrap;
92
+ }
93
+
94
+ .tab {
95
+ padding: 8px 16px;
96
+ background: var(--tab-bg);
97
+ border: 1px solid var(--tab-border);
98
+ border-radius: 4px;
99
+ cursor: pointer;
100
+ font-size: 14px;
101
+ text-decoration: none;
102
+ color: var(--fg);
103
+ display: inline-block;
104
+ }
105
+
106
+ .tab:hover {
107
+ background: var(--tab-hover);
108
+ text-decoration: none;
109
+ }
110
+
111
+ .tab.active {
112
+ background: var(--active-bg);
113
+ color: var(--active-fg);
114
+ border-color: var(--active-bg);
115
+ }
116
+
117
+ .icon-grid {
118
+ display: grid;
119
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
120
+ gap: 20px;
121
+ margin-top: 30px;
122
+ }
123
+
124
+ .icon-item {
125
+ text-align: center;
126
+ padding: 15px;
127
+ border: 1px solid var(--border);
128
+ border-radius: 4px;
129
+ cursor: pointer;
130
+ }
131
+
132
+ .icon-item:hover {
133
+ background: var(--icon-hover);
134
+ border-color: var(--icon-border-hover);
135
+ }
136
+
137
+ .icon-item svg {
138
+ width: 48px;
139
+ height: 48px;
140
+ margin-bottom: 8px;
141
+ display: block;
142
+ margin-left: auto;
143
+ margin-right: auto;
144
+ }
145
+
146
+ .icon-name {
147
+ font-size: 12px;
148
+ color: var(--muted);
149
+ word-break: break-all;
150
+ }
151
+
152
+ .stats {
153
+ margin-top: 20px;
154
+ font-size: 14px;
155
+ color: var(--muted);
156
+ }
157
+
158
+ .copy-toast {
159
+ position: fixed;
160
+ bottom: 20px;
161
+ right: 20px;
162
+ background: var(--active-bg);
163
+ color: var(--active-fg);
164
+ padding: 12px 20px;
165
+ border-radius: 4px;
166
+ opacity: 0;
167
+ transform: translateY(20px);
168
+ transition: all 0.3s ease;
169
+ pointer-events: none;
170
+ font-size: 14px;
171
+ }
172
+
173
+ .copy-toast.show {
174
+ opacity: 1;
175
+ transform: translateY(0);
176
+ }
177
+
178
+ .no-results {
179
+ text-align: center;
180
+ color: var(--no-results);
181
+ padding: 40px;
182
+ font-size: 14px;
183
+ }
184
+
185
+ [hidden] {
186
+ display: none !important;
187
+ }
188
+ </style>
189
+ <link rel="icon" href="<%= url "/public/favicon.png" %>" type="image/png">
190
+ <link rel="apple-touch-icon" href="<%= url "/public/favicon.png" %>">
191
+ </head>
192
+ <body>
193
+ <div class="header">
194
+ <button class="theme-switcher" id="themeSwitcher">🌙</button>
195
+
196
+ <div class="search-box">
197
+ <input
198
+ type="text"
199
+ id="searchInput"
200
+ placeholder="Search icons..."
201
+ value="<%= escape(@search_query) %>"
202
+ autocomplete="off"
203
+ >
204
+ </div>
205
+
206
+ <div class="tabs">
207
+ <% @libraries.each do |library| %>
208
+ <a href="<%= url(library.name, @request.params) %>"
209
+ class="tab <%= library == @selected_library ? 'active' : '' %>">
210
+ <%= library.name %> (<%= library.icons.length %>)
211
+ </a>
212
+ <% end %>
213
+ </div>
214
+ </div>
215
+
216
+ <div class="stats" id="stats"></div>
217
+
218
+ <div class="icon-grid" id="iconGrid"></div>
219
+
220
+ <div class="copy-toast" id="copyToast">Copied to clipboard!</div>
221
+
222
+ <script>
223
+ // Icon data for current library only
224
+ const currentLibrary = '<%= @selected_library.name %>';
225
+ const iconsData = <%= @selected_library.icons.to_json %>;
226
+
227
+ // State
228
+ let searchQuery = '<%= @search_query %>';
229
+
230
+ // Elements
231
+ const searchInput = document.getElementById('searchInput');
232
+ const iconGrid = document.getElementById('iconGrid');
233
+ const stats = document.getElementById('stats');
234
+ const copyToast = document.getElementById('copyToast');
235
+ const themeSwitcher = document.getElementById('themeSwitcher');
236
+
237
+ // Functions
238
+ function filterIcons() {
239
+ const query = searchQuery.toLowerCase();
240
+ const filtered = query
241
+ ? iconsData.filter(icon => icon.name.toLowerCase().includes(query))
242
+ : iconsData;
243
+
244
+ renderIcons(filtered);
245
+ updateStats(filtered.length);
246
+ }
247
+
248
+ function renderIcons(icons) {
249
+ if (icons.length === 0) {
250
+ iconGrid.innerHTML = '<div class="no-results">No icons found</div>';
251
+ return;
252
+ }
253
+
254
+ iconGrid.innerHTML = icons.map(icon => {
255
+ return `
256
+ <div class="icon-item" data-icon="${icon.name}">
257
+ ${icon.svg}
258
+ <div class="icon-name">${icon.name}</div>
259
+ </div>
260
+ `;
261
+ }).join('');
262
+ }
263
+
264
+ function updateStats(count) {
265
+ stats.textContent = `Showing ${count} icons from ${currentLibrary}`;
266
+ }
267
+
268
+ function copyIcon(iconName) {
269
+ const text = `${iconName}`;
270
+ navigator.clipboard.writeText(text).then(() => {
271
+ copyToast.textContent = `Copied: ${text}`;
272
+ copyToast.classList.add('show');
273
+ setTimeout(() => copyToast.classList.remove('show'), 2000);
274
+ });
275
+ }
276
+
277
+ function updateURL() {
278
+ const params = new URLSearchParams();
279
+ if (searchQuery) {
280
+ params.set('q', searchQuery);
281
+ }
282
+ const queryString = params.toString();
283
+ const newURL = window.location.pathname + (queryString ? '?' + queryString : '');
284
+ window.history.replaceState({}, '', newURL);
285
+
286
+ // Update all tab links with new query
287
+ document.querySelectorAll('.tab').forEach(tab => {
288
+ const href = tab.getAttribute('href').split('?')[0];
289
+ tab.setAttribute('href', href + (queryString ? '?' + queryString : ''));
290
+ });
291
+ }
292
+
293
+ // Theme management
294
+ function loadTheme() {
295
+ const savedTheme = localStorage.getItem('theme') || 'light';
296
+ document.documentElement.setAttribute('data-theme', savedTheme);
297
+ themeSwitcher.textContent = savedTheme === 'dark' ? '☀️' : '🌙';
298
+ }
299
+
300
+ function toggleTheme() {
301
+ const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
302
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
303
+ document.documentElement.setAttribute('data-theme', newTheme);
304
+ localStorage.setItem('theme', newTheme);
305
+ themeSwitcher.textContent = newTheme === 'dark' ? '☀️' : '🌙';
306
+ }
307
+
308
+ // Event listeners
309
+ searchInput.addEventListener('input', (e) => {
310
+ searchQuery = e.target.value;
311
+ filterIcons();
312
+ updateURL();
313
+ });
314
+
315
+ iconGrid.addEventListener('click', (e) => {
316
+ const iconItem = e.target.closest('.icon-item');
317
+ if (iconItem) {
318
+ copyIcon(iconItem.dataset.icon);
319
+ }
320
+ });
321
+
322
+ themeSwitcher.addEventListener('click', toggleTheme);
323
+
324
+ // Initialize
325
+ loadTheme();
326
+ filterIcons();
327
+ </script>
328
+ </body>
329
+ </html>