kiso-icons 0.1.0.pre

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6f91c4c7b067ad325b199468604b89aead98ba16f3c99469aff5e2ef836d4878
4
+ data.tar.gz: d9f4b770b5081f8bbd463591710dadb8b9e198b4526bb226d60563f065b92c1e
5
+ SHA512:
6
+ metadata.gz: 2db2872c247bc11a982224eff8feb89ddc63b2ba51b96aba1783e5c9fab9dadf73b336554fb0caa13b26014028e64d0be5ad5db0656382c472650a179ba1fdd2
7
+ data.tar.gz: 9633012b948ac6fb942ef2b51408dc4c1cd64f82daf61d4c3cadb4d1957bd43d377afab5cdc9f376ba38f8a307bf39608ef3d97d13bb7c4dba7e9cd0642b4194
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0.pre] - 2026-02-25
11
+
12
+ ### Added
13
+
14
+ - Inline SVG rendering via `kiso_icon_tag` helper
15
+ - CLI (`bin/kiso-icons`) for pinning, unpinning, and listing icon sets
16
+ - Support for all 224 Iconify icon sets (299k+ icons)
17
+ - Vendored Lucide icon set as zero-config default (81KB gzipped)
18
+ - Resolution cascade: vendored JSON → bundled Lucide → Iconify API fallback
19
+ - Thread-safe in-memory icon cache
20
+ - Alias resolution with rotation/flip transforms (up to 5 levels)
21
+ - Iconify API fallback in development with prompt to pin
22
+ - Rails generator (`kiso_icons:install`) for project setup
23
+ - `pristine` command to re-download all pinned sets
24
+
25
+ [Unreleased]: https://github.com/steveclarke/kiso-icons/compare/v0.1.0.pre...HEAD
26
+ [0.1.0.pre]: https://github.com/steveclarke/kiso-icons/releases/tag/v0.1.0.pre
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) Stephen Clarke
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # Kiso Icons 基礎
2
+
3
+ Iconify icons for Rails. Use any of [224 icon sets](https://icon-sets.iconify.design/) (299k+ icons) as inline SVG. No JavaScript needed.
4
+
5
+ Icons live in `vendor/icons/` as JSON files. Commit them to git, just like vendored JavaScript with importmap-rails.
6
+
7
+ Part of the [Kiso 基礎](https://github.com/steveclarke/kiso) family. Works on its own in any Rails 8+ app.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ ./bin/bundle add kiso-icons
13
+ ./bin/rails kiso_icons:install
14
+ ```
15
+
16
+ This creates `vendor/icons/` and a `bin/kiso-icons` binstub.
17
+
18
+ ## Quick start
19
+
20
+ [Lucide](https://lucide.dev) ships with the gem. Use it right away:
21
+
22
+ ```erb
23
+ <%= kiso_icon_tag("check") %>
24
+ <%= kiso_icon_tag("arrow-right") %>
25
+ ```
26
+
27
+ Want a different icon set? Pin it:
28
+
29
+ ```bash
30
+ ./bin/kiso-icons pin heroicons
31
+ ```
32
+
33
+ Then use it with the set name as a prefix:
34
+
35
+ ```erb
36
+ <%= kiso_icon_tag("heroicons:home") %>
37
+ ```
38
+
39
+ Browse all sets at [icon-sets.iconify.design](https://icon-sets.iconify.design/).
40
+
41
+ ## Usage
42
+
43
+ ```erb
44
+ <%= kiso_icon_tag("lucide:check") %>
45
+ <%= kiso_icon_tag("check") %> <%# uses the default set (lucide) %>
46
+ <%= kiso_icon_tag("check", class: "w-5 h-5") %> <%# add CSS classes %>
47
+ <%= kiso_icon_tag("check", aria: { label: "Done" }) %> <%# screen reader label %>
48
+ ```
49
+
50
+ You can pass any HTML attribute. If you add `aria: { label: "..." }`, screen readers will see the icon and `aria-hidden` is removed.
51
+
52
+ ### Pin icon sets
53
+
54
+ The `pin` command downloads an Iconify JSON file to `vendor/icons/`. Commit it to git. In production, icons load from these files with no API calls.
55
+
56
+ ```bash
57
+ ./bin/kiso-icons pin lucide # pin Lucide (replaces the bundled copy)
58
+ ./bin/kiso-icons pin heroicons mdi # pin more than one set at once
59
+ ./bin/kiso-icons unpin heroicons # remove a set
60
+ ./bin/kiso-icons pristine # re-download all pinned sets
61
+ ./bin/kiso-icons list # show what's pinned
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ Add an initializer to change the defaults:
67
+
68
+ ```ruby
69
+ # config/initializers/kiso_icons.rb
70
+ Kiso::Icons.configure do |config|
71
+ config.default_set = "lucide" # icon set used when no prefix is given
72
+ config.vendor_path = "vendor/icons" # where pinned JSON files are stored
73
+ config.fallback_to_api = false # fetch missing icons from the Iconify API
74
+ end
75
+ ```
76
+
77
+ | Option | Default | What it does |
78
+ |--------|---------|-------------|
79
+ | `default_set` | `"lucide"` | Icon set used when you write `kiso_icon_tag("check")` with no prefix. |
80
+ | `vendor_path` | `"vendor/icons"` | Where pinned JSON files are stored. |
81
+ | `fallback_to_api` | `true` in dev, `false` in prod | If `true`, missing icons are fetched from the Iconify API. A log message tells you to pin the set. |
82
+
83
+ > [!TIP]
84
+ > In production, `fallback_to_api` is off. Pin all the sets you need so icons load from local files.
85
+
86
+ ## How it works
87
+
88
+ Icons are found in this order:
89
+
90
+ 1. **Pinned JSON** — your sets in `vendor/icons/`
91
+ 2. **Bundled Lucide** — ships with the gem (81 KB gzipped), works with no setup
92
+ 3. **Iconify API** — only in dev mode, with a prompt to pin
93
+
94
+ Each SVG uses `width="1em"`, `height="1em"`, and `currentColor`. It inherits its size from `font-size` and its color from the parent element. No CSS framework needed.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ git clone https://github.com/steveclarke/kiso-icons.git
100
+ cd kiso-icons
101
+ bin/setup # install deps + pin demo icon sets
102
+ bin/dev # start demo app at http://localhost:3100
103
+ ```
104
+
105
+ `bin/dev` starts a Rails app that shows icons from 10 sets. You can change the port: `bin/dev 4200`.
106
+
107
+ Run tests:
108
+
109
+ ```bash
110
+ bundle exec rake test # unit tests (no Rails)
111
+ bundle exec rake test_integration # integration tests (boots Rails)
112
+ bundle exec rake # both
113
+ ```
114
+
115
+ See [CONTRIBUTING.md](CONTRIBUTING.md) to help out.
116
+
117
+ ## Project layout
118
+
119
+ ```
120
+ bin/ dev scripts and CLI binstub
121
+ data/ bundled Lucide icon set (gzipped JSON)
122
+ lib/ gem source code
123
+ test/ unit and integration tests
124
+ test/dummy/ Rails app used for integration tests and the demo
125
+ ```
126
+
127
+ ## Requirements
128
+
129
+ - Ruby >= 3.2
130
+ - Rails >= 8.0
131
+
132
+ ## License
133
+
134
+ MIT License. See [MIT-LICENSE](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/*_test.rb"]
10
+ end
11
+
12
+ Rake::TestTask.new(:test_integration) do |t|
13
+ t.libs << "test"
14
+ t.libs << "lib"
15
+ t.test_files = FileList["test/integration/**/*_test.rb"]
16
+ end
17
+
18
+ task test_all: [:test, :test_integration]
19
+ task default: :test_all
Binary file
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../config/application"
4
+ require "kiso/icons/commands"
5
+
6
+ Kiso::Icons::Commands.start(ARGV)
@@ -0,0 +1,7 @@
1
+ say "Use vendor/icons for pinned icon sets"
2
+ empty_directory "vendor/icons"
3
+ keep_file "vendor/icons"
4
+
5
+ say "Copying binstub"
6
+ copy_file "#{__dir__}/bin/kiso-icons", "bin/kiso-icons"
7
+ chmod "bin", 0o755 & ~File.umask, verbose: false
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Kiso
8
+ module Icons
9
+ class ApiClient
10
+ API_BASE = "https://api.iconify.design"
11
+ TIMEOUT = 5
12
+
13
+ class << self
14
+ def fetch_icon(set_prefix, icon_name)
15
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+
17
+ uri = URI("#{API_BASE}/#{set_prefix}/#{icon_name}.json")
18
+ response = make_request(uri)
19
+ return nil unless response
20
+
21
+ data = JSON.parse(response.body)
22
+ return nil unless data["body"]
23
+
24
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
25
+
26
+ warn_to_pin(set_prefix, icon_name, elapsed_ms)
27
+
28
+ {
29
+ body: data["body"],
30
+ width: data["width"] || 24,
31
+ height: data["height"] || 24
32
+ }
33
+ rescue JSON::ParserError => e
34
+ warn "[Kiso::Icons] Failed to parse API response for #{set_prefix}:#{icon_name}: #{e.message}"
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ def make_request(uri)
41
+ http = Net::HTTP.new(uri.host, uri.port)
42
+ http.use_ssl = true
43
+ http.open_timeout = TIMEOUT
44
+ http.read_timeout = TIMEOUT
45
+
46
+ request = Net::HTTP::Get.new(uri)
47
+ request["Accept"] = "application/json"
48
+
49
+ response = http.request(request)
50
+
51
+ case response
52
+ when Net::HTTPSuccess then response
53
+ when Net::HTTPNotFound then nil
54
+ else
55
+ warn "[Kiso::Icons] API returned #{response.code} for #{uri}"
56
+ nil
57
+ end
58
+ rescue Net::OpenTimeout, Net::ReadTimeout
59
+ warn "[Kiso::Icons] API timeout for #{uri} (#{TIMEOUT}s)"
60
+ nil
61
+ rescue SocketError, Errno::ECONNREFUSED => e
62
+ warn "[Kiso::Icons] Network error: #{e.message}"
63
+ nil
64
+ end
65
+
66
+ def warn_to_pin(set_prefix, icon_name, elapsed_ms)
67
+ message = "[Kiso::Icons] Fetched #{set_prefix}:#{icon_name} from Iconify API (#{elapsed_ms}ms).\n" \
68
+ " Pin this set for offline use: bin/kiso-icons pin #{set_prefix}"
69
+
70
+ if defined?(Rails) && defined?(Rails.logger)
71
+ Rails.logger.debug { message }
72
+ else
73
+ warn message
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiso
4
+ module Icons
5
+ class Cache
6
+ def initialize
7
+ @store = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def get(set_prefix, name)
12
+ @mutex.synchronize { @store["#{set_prefix}:#{name}"] }
13
+ end
14
+
15
+ def set(set_prefix, name, data)
16
+ @mutex.synchronize { @store["#{set_prefix}:#{name}"] = data.freeze }
17
+ end
18
+
19
+ def clear!
20
+ @mutex.synchronize { @store.clear }
21
+ end
22
+
23
+ def size
24
+ @mutex.synchronize { @store.size }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "kiso/icons"
5
+
6
+ class Kiso::Icons::Commands < Thor
7
+ include Thor::Actions
8
+
9
+ SETS_URL = "https://raw.githubusercontent.com/iconify/icon-sets/master/json"
10
+
11
+ def self.exit_on_failure? = false
12
+
13
+ desc "pin SETS...", "Download icon sets to vendor/icons/"
14
+ long_desc <<~DESC
15
+ Downloads Iconify JSON files for the specified icon sets to vendor/icons/.
16
+ Commit these files to git for production use (like importmap-rails vendor pattern).
17
+
18
+ Example:
19
+ $ bin/kiso-icons pin lucide
20
+ $ bin/kiso-icons pin heroicons mdi tabler
21
+ DESC
22
+ def pin(*sets)
23
+ if sets.empty?
24
+ say "Usage: bin/kiso-icons pin SET [SET...]", :red
25
+ say ""
26
+ say "Example: bin/kiso-icons pin lucide heroicons"
27
+ say "Browse sets: https://icon-sets.iconify.design/"
28
+ exit 1
29
+ end
30
+
31
+ vendor_dir = vendor_path
32
+ FileUtils.mkdir_p(vendor_dir)
33
+
34
+ sets.each { |set_name| pin_set(set_name, vendor_dir) }
35
+ end
36
+
37
+ desc "unpin SET", "Remove a vendored icon set"
38
+ def unpin(set_name)
39
+ path = File.join(vendor_path, "#{set_name}.json")
40
+
41
+ unless File.exist?(path)
42
+ say " not found #{set_name} is not pinned", :red
43
+ return
44
+ end
45
+
46
+ File.delete(path)
47
+ say " remove vendor/icons/#{set_name}.json", :green
48
+ end
49
+
50
+ desc "pristine", "Re-download all pinned icon sets"
51
+ def pristine
52
+ sets = vendored_sets
53
+ if sets.empty?
54
+ say "No icon sets pinned. Pin one with: bin/kiso-icons pin lucide", :yellow
55
+ return
56
+ end
57
+
58
+ vendor_dir = vendor_path
59
+ say "Re-downloading #{sets.size} pinned set(s)..."
60
+ sets.each { |set_name| pin_set(set_name, vendor_dir, overwrite: true) }
61
+ end
62
+
63
+ desc "list", "Show pinned icon sets"
64
+ def list
65
+ sets = vendored_sets
66
+ if sets.empty?
67
+ say "No icon sets pinned.", :yellow
68
+ say ""
69
+ say "Pin a set: bin/kiso-icons pin lucide"
70
+ return
71
+ end
72
+
73
+ say "Pinned icon sets (vendor/icons/):", :cyan
74
+ say ""
75
+
76
+ sets.each do |set_name|
77
+ path = File.join(vendor_path, "#{set_name}.json")
78
+ size_kb = (File.size(path) / 1024.0).round(1)
79
+ data = JSON.parse(File.read(path))
80
+ icon_count = (data["icons"] || {}).size
81
+ display_name = data.dig("info", "name") || set_name
82
+
83
+ say " #{set_name.ljust(20)} #{icon_count.to_s.rjust(6)} icons #{size_kb.to_s.rjust(8)} KB (#{display_name})"
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def pin_set(set_name, vendor_dir, overwrite: false)
90
+ dest = File.join(vendor_dir, "#{set_name}.json")
91
+
92
+ if File.exist?(dest) && !overwrite
93
+ say " exists vendor/icons/#{set_name}.json (use `pristine` to re-download)", :yellow
94
+ return
95
+ end
96
+
97
+ url = "#{SETS_URL}/#{set_name}.json"
98
+ say " fetch #{url}"
99
+
100
+ body = download(url)
101
+ if body.nil?
102
+ say " error Could not download #{set_name}. Check the set name.", :red
103
+ say " Browse sets: https://icon-sets.iconify.design/"
104
+ return
105
+ end
106
+
107
+ body = body.force_encoding("UTF-8")
108
+
109
+ data = JSON.parse(body)
110
+ unless data["icons"]
111
+ say " error #{set_name}.json has no 'icons' key -- invalid Iconify format", :red
112
+ return
113
+ end
114
+
115
+ File.write(dest, body)
116
+ icon_count = data["icons"].size
117
+ size_kb = (body.bytesize / 1024.0).round(1)
118
+ say " pin vendor/icons/#{set_name}.json (#{icon_count} icons, #{size_kb} KB)", :green
119
+ end
120
+
121
+ def download(url, redirect_limit = 5)
122
+ raise "Too many redirects" if redirect_limit == 0
123
+
124
+ uri = URI(url)
125
+ response = Net::HTTP.get_response(uri)
126
+ case response
127
+ when Net::HTTPSuccess then response.body
128
+ when Net::HTTPRedirection then download(response["location"], redirect_limit - 1)
129
+ end
130
+ rescue SocketError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
131
+ say " error Network error: #{e.message}", :red
132
+ nil
133
+ end
134
+
135
+ def vendor_path
136
+ base = if defined?(Rails) && Rails.root
137
+ Rails.root.to_s
138
+ else
139
+ Dir.pwd
140
+ end
141
+ File.join(base, Kiso::Icons.configuration.vendor_path)
142
+ end
143
+
144
+ def vendored_sets
145
+ Dir.glob(File.join(vendor_path, "*.json"))
146
+ .map { |f| File.basename(f, ".json") }
147
+ .sort
148
+ end
149
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiso
4
+ module Icons
5
+ class Configuration
6
+ attr_accessor :default_set, :vendor_path, :fallback_to_api
7
+
8
+ def initialize
9
+ @default_set = "lucide"
10
+ @vendor_path = "vendor/icons"
11
+ @fallback_to_api = defined?(Rails) ? Rails.env.development? : false
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Kiso
6
+ module Icons
7
+ module Helper
8
+ # Renders an inline SVG icon from Iconify icon sets.
9
+ #
10
+ # kiso_icon_tag("lucide:check")
11
+ # kiso_icon_tag("check") # uses default set (lucide)
12
+ # kiso_icon_tag("check", class: "w-5 h-5") # pass any CSS classes
13
+ # kiso_icon_tag("check", aria: { label: "Done" }) # accessible icon
14
+ #
15
+ def kiso_icon_tag(name, **options)
16
+ icon_data = Kiso::Icons.resolve(name.to_s)
17
+
18
+ unless icon_data
19
+ if defined?(Rails) && Rails.env.development?
20
+ return safe_string("<!-- kiso-icons: '#{ERB::Util.html_escape(name)}' not found -->")
21
+ end
22
+ return safe_string("")
23
+ end
24
+
25
+ Kiso::Icons::Renderer.render(icon_data, css_class: options.delete(:class), **options)
26
+ end
27
+
28
+ private
29
+
30
+ def safe_string(str)
31
+ if defined?(ActiveSupport::SafeBuffer)
32
+ ActiveSupport::SafeBuffer.new(str)
33
+ else
34
+ str
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kiso/icons/helper"
4
+
5
+ module Kiso
6
+ module Icons
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "kiso_icons.configure" do |_app|
9
+ Kiso::Icons.configure do |config|
10
+ config.fallback_to_api = Rails.env.development? || Rails.env.test?
11
+ end
12
+ end
13
+
14
+ initializer "kiso_icons.helpers" do
15
+ ActiveSupport.on_load(:action_view) do
16
+ include Kiso::Icons::Helper
17
+ end
18
+ end
19
+
20
+ rake_tasks do
21
+ load "tasks/kiso_icons_tasks.rake"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiso
4
+ module Icons
5
+ class Renderer
6
+ class << self
7
+ def render(icon_data, css_class: nil, **options)
8
+ body = icon_data[:body]
9
+ width = icon_data[:width]
10
+ height = icon_data[:height]
11
+
12
+ attrs = {
13
+ "xmlns" => "http://www.w3.org/2000/svg",
14
+ "viewBox" => "0 0 #{width} #{height}",
15
+ "width" => "1em",
16
+ "height" => "1em",
17
+ "aria-hidden" => "true",
18
+ "fill" => "none"
19
+ }
20
+
21
+ attrs["class"] = css_class if css_class && !css_class.empty?
22
+
23
+ options.each do |key, value|
24
+ if key == :data && value.is_a?(Hash)
25
+ value.each { |k, v| attrs["data-#{k.to_s.tr("_", "-")}"] = v.to_s }
26
+ elsif key == :aria && value.is_a?(Hash)
27
+ value.each { |k, v| attrs["aria-#{k.to_s.tr("_", "-")}"] = v.to_s }
28
+ else
29
+ attrs[key.to_s.tr("_", "-")] = value.to_s
30
+ end
31
+ end
32
+
33
+ if attrs.key?("aria-label")
34
+ attrs.delete("aria-hidden")
35
+ attrs["role"] = "img"
36
+ end
37
+
38
+ attr_str = attrs.map { |k, v| %(#{k}="#{escape_attr(v)}") }.join(" ")
39
+ svg = %(<svg #{attr_str}>#{body}</svg>)
40
+
41
+ if defined?(ActiveSupport::SafeBuffer)
42
+ ActiveSupport::SafeBuffer.new(svg)
43
+ else
44
+ svg
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def escape_attr(value)
51
+ value.to_s
52
+ .gsub("&", "&amp;")
53
+ .gsub('"', "&quot;")
54
+ .gsub("<", "&lt;")
55
+ .gsub(">", "&gt;")
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiso
4
+ module Icons
5
+ class Resolver
6
+ def initialize
7
+ @loaded_sets = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def resolve(name)
12
+ set_prefix, icon_name = parse_name(name)
13
+
14
+ # 1. In-memory cache
15
+ cached = Kiso::Icons.cache.get(set_prefix, icon_name)
16
+ return cached if cached
17
+
18
+ # 2. Already-loaded set
19
+ icon_data = resolve_from_loaded_set(set_prefix, icon_name)
20
+
21
+ # 3. Vendored JSON
22
+ icon_data ||= resolve_from_vendor(set_prefix, icon_name)
23
+
24
+ # 4. Bundled (lucide ships in gem)
25
+ icon_data ||= resolve_from_bundled(set_prefix, icon_name)
26
+
27
+ # 5. API fallback (dev only)
28
+ icon_data ||= resolve_from_api(set_prefix, icon_name)
29
+
30
+ Kiso::Icons.cache.set(set_prefix, icon_name, icon_data) if icon_data
31
+
32
+ icon_data
33
+ end
34
+
35
+ def clear!
36
+ @mutex.synchronize { @loaded_sets.clear }
37
+ end
38
+
39
+ private
40
+
41
+ def parse_name(name)
42
+ name = name.to_s.strip
43
+ if name.include?(":")
44
+ name.split(":", 2)
45
+ else
46
+ [Kiso::Icons.configuration.default_set, name]
47
+ end
48
+ end
49
+
50
+ def resolve_from_loaded_set(set_prefix, icon_name)
51
+ set = @mutex.synchronize { @loaded_sets[set_prefix] }
52
+ set&.icon(icon_name)
53
+ end
54
+
55
+ def resolve_from_vendor(set_prefix, icon_name)
56
+ set = Set.from_vendor(set_prefix)
57
+ return nil unless set
58
+
59
+ @mutex.synchronize { @loaded_sets[set_prefix] = set }
60
+ set.icon(icon_name)
61
+ end
62
+
63
+ def resolve_from_bundled(set_prefix, icon_name)
64
+ set = Set.from_bundled(set_prefix)
65
+ return nil unless set
66
+
67
+ @mutex.synchronize { @loaded_sets[set_prefix] = set }
68
+ set.icon(icon_name)
69
+ end
70
+
71
+ def resolve_from_api(set_prefix, icon_name)
72
+ return nil unless Kiso::Icons.configuration.fallback_to_api
73
+
74
+ ApiClient.fetch_icon(set_prefix, icon_name)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+ require "stringio"
6
+ require "pathname"
7
+
8
+ module Kiso
9
+ module Icons
10
+ class Set
11
+ attr_reader :prefix, :default_width, :default_height
12
+
13
+ def initialize(prefix:, data:)
14
+ @prefix = prefix
15
+ @icons = data["icons"] || {}
16
+ @aliases = data["aliases"] || {}
17
+ @default_width = data["width"] || 24
18
+ @default_height = data["height"] || 24
19
+ @info = data["info"] || {}
20
+ end
21
+
22
+ def icon(name)
23
+ if (icon_data = @icons[name])
24
+ return build_icon_data(icon_data)
25
+ end
26
+
27
+ resolved_name = resolve_alias(name)
28
+ if resolved_name && (icon_data = @icons[resolved_name])
29
+ alias_data = @aliases[name]
30
+ return build_icon_data(icon_data, alias_transforms: alias_data)
31
+ end
32
+
33
+ nil
34
+ end
35
+
36
+ def icon_names
37
+ @icons.keys + @aliases.keys
38
+ end
39
+
40
+ def icon_count
41
+ @icons.size
42
+ end
43
+
44
+ def display_name
45
+ @info["name"] || @prefix
46
+ end
47
+
48
+ class << self
49
+ def from_vendor(prefix)
50
+ path = vendor_path_for(prefix)
51
+ return nil unless File.exist?(path)
52
+
53
+ data = JSON.parse(File.read(path))
54
+ new(prefix: prefix, data: data)
55
+ end
56
+
57
+ def from_bundled(prefix)
58
+ path = bundled_path_for(prefix)
59
+ return nil unless File.exist?(path)
60
+
61
+ gz_data = File.binread(path)
62
+ json_str = Zlib::GzipReader.new(StringIO.new(gz_data)).read
63
+ data = JSON.parse(json_str)
64
+ new(prefix: prefix, data: data)
65
+ end
66
+
67
+ def vendor_path_for(prefix)
68
+ base = if defined?(Rails) && Rails.root
69
+ Rails.root
70
+ else
71
+ Pathname.new(Dir.pwd)
72
+ end
73
+ base.join(Kiso::Icons.configuration.vendor_path, "#{prefix}.json").to_s
74
+ end
75
+
76
+ def bundled_path_for(prefix)
77
+ File.join(gem_data_path, "#{prefix}.json.gz")
78
+ end
79
+
80
+ def gem_data_path
81
+ File.expand_path("../../../../data", __FILE__)
82
+ end
83
+
84
+ def vendored_sets
85
+ pattern = if defined?(Rails) && Rails.root
86
+ Rails.root.join(Kiso::Icons.configuration.vendor_path, "*.json").to_s
87
+ else
88
+ File.join(Dir.pwd, Kiso::Icons.configuration.vendor_path, "*.json")
89
+ end
90
+
91
+ Dir.glob(pattern).map { |f| File.basename(f, ".json") }.sort
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def resolve_alias(name, depth: 0)
98
+ return nil if depth > 5
99
+ alias_entry = @aliases[name]
100
+ return nil unless alias_entry
101
+
102
+ parent = alias_entry["parent"]
103
+ return parent if @icons.key?(parent)
104
+
105
+ resolve_alias(parent, depth: depth + 1)
106
+ end
107
+
108
+ def build_icon_data(icon_data, alias_transforms: nil)
109
+ body = icon_data["body"]
110
+ width = icon_data["width"] || @default_width
111
+ height = icon_data["height"] || @default_height
112
+
113
+ if alias_transforms
114
+ body = apply_transforms(body, alias_transforms, width, height)
115
+ width = alias_transforms["width"] || width
116
+ height = alias_transforms["height"] || height
117
+ end
118
+
119
+ {body: body, width: width, height: height}
120
+ end
121
+
122
+ def apply_transforms(body, transforms, width, height)
123
+ parts = []
124
+
125
+ if transforms["rotate"]
126
+ degrees = transforms["rotate"] * 90
127
+ parts << "rotate(#{degrees} #{width / 2.0} #{height / 2.0})"
128
+ end
129
+
130
+ scale_x = transforms["hFlip"] ? -1 : 1
131
+ scale_y = transforms["vFlip"] ? -1 : 1
132
+
133
+ if scale_x != 1 || scale_y != 1
134
+ tx = transforms["hFlip"] ? width : 0
135
+ ty = transforms["vFlip"] ? height : 0
136
+ parts << "translate(#{tx} #{ty})" if tx != 0 || ty != 0
137
+ parts << "scale(#{scale_x} #{scale_y})"
138
+ end
139
+
140
+ if parts.any?
141
+ %(<g transform="#{parts.join(" ")}">#{body}</g>)
142
+ else
143
+ body
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiso
4
+ module Icons
5
+ VERSION = "0.1.0.pre"
6
+ end
7
+ end
data/lib/kiso/icons.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiso
4
+ module Icons
5
+ class Error < StandardError; end
6
+ class IconNotFound < Error; end
7
+ class SetNotFound < Error; end
8
+
9
+ class << self
10
+ def configuration
11
+ @configuration ||= Configuration.new
12
+ end
13
+
14
+ def configure
15
+ yield(configuration)
16
+ end
17
+
18
+ def resolver
19
+ @resolver ||= Resolver.new
20
+ end
21
+
22
+ def cache
23
+ @cache ||= Cache.new
24
+ end
25
+
26
+ def resolve(name)
27
+ resolver.resolve(name)
28
+ end
29
+
30
+ def reset!
31
+ @resolver = nil
32
+ @cache = nil
33
+ @configuration = nil
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ require "kiso/icons/version"
40
+ require "kiso/icons/configuration"
41
+ require "kiso/icons/cache"
42
+ require "kiso/icons/set"
43
+ require "kiso/icons/resolver"
44
+ require "kiso/icons/renderer"
45
+ require "kiso/icons/api_client"
46
+ require "kiso/icons/railtie" if defined?(Rails::Railtie)
data/lib/kiso-icons.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kiso/icons"
@@ -0,0 +1,9 @@
1
+ namespace :kiso_icons do
2
+ desc "Setup kiso-icons for the app"
3
+ task :install do
4
+ previous_location = ENV["LOCATION"]
5
+ ENV["LOCATION"] = File.expand_path("../install/install.rb", __dir__)
6
+ Rake::Task["app:template"].invoke
7
+ ENV["LOCATION"] = previous_location
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kiso-icons
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre
5
+ platform: ruby
6
+ authors:
7
+ - Steve Clarke
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: actionpack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '8.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '8.0'
54
+ description: Pin any of Iconify's 224 icon sets (299k icons) to vendor/icons/. Inline
55
+ SVG rendering, zero JavaScript, vendored for production.
56
+ email:
57
+ - steve@sevenview.ca
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - MIT-LICENSE
64
+ - README.md
65
+ - Rakefile
66
+ - data/lucide.json.gz
67
+ - lib/install/bin/kiso-icons
68
+ - lib/install/install.rb
69
+ - lib/kiso-icons.rb
70
+ - lib/kiso/icons.rb
71
+ - lib/kiso/icons/api_client.rb
72
+ - lib/kiso/icons/cache.rb
73
+ - lib/kiso/icons/commands.rb
74
+ - lib/kiso/icons/configuration.rb
75
+ - lib/kiso/icons/helper.rb
76
+ - lib/kiso/icons/railtie.rb
77
+ - lib/kiso/icons/renderer.rb
78
+ - lib/kiso/icons/resolver.rb
79
+ - lib/kiso/icons/set.rb
80
+ - lib/kiso/icons/version.rb
81
+ - lib/tasks/kiso_icons_tasks.rake
82
+ homepage: https://github.com/steveclarke/kiso-icons
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://github.com/steveclarke/kiso-icons
87
+ source_code_uri: https://github.com/steveclarke/kiso-icons
88
+ changelog_uri: https://github.com/steveclarke/kiso-icons/blob/master/CHANGELOG.md
89
+ bug_tracker_uri: https://github.com/steveclarke/kiso-icons/issues
90
+ rubygems_mfa_required: 'true'
91
+ allowed_push_host: https://rubygems.org
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '3.2'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 3.6.9
107
+ specification_version: 4
108
+ summary: Iconify icons for Rails
109
+ test_files: []