glyphs 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1a379bf83aad5e7a6adfc47f2f8cced12baa7ce0e970b0b675747445f8ab9f99
4
+ data.tar.gz: d134dcee770ac2629575ef80b020b9e61982e55bd8affd0e944ce5316ad22e4d
5
+ SHA512:
6
+ metadata.gz: 8d0a4346f8b888f8be02ec3403b46262dfa5593276d767cc37215e5eb03d2c2a7261d7f9709ddb2fad1d1715220dfcecfd6c5dbb928818810cf5442c62320c2e
7
+ data.tar.gz: 444366fd42ba9ba2a8b422a0b2ad8ec8674bc737e477fc10e91cc7f7890b9cdc52bb76eb8b5056fd87c80123a959f2fe919e25f1e95be2f09abfcb2746494961
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-07-07
4
+
5
+ ### Added
6
+
7
+ - `Glyphs::Icon` base Phlex component rendering SVGs through the `icons` gem (rails_icons).
8
+ - Library components exposed via `Phlex::Kit`: `LucideIcon`, `PhosphorIcon`, `HeroIcon`, `TablerIcon`,
9
+ `FeatherIcon`, `BoxIcon`, `FlagIcon`, `HugeIcon`, `LinearIcon`, `RadixIcon`, `SidekickIcon`,
10
+ `WeatherIcon`, `AnimatedIcon`.
11
+ - `Glyphs.register_library` for custom icon libraries.
12
+ - Configurable missing-icon policy (`raise_on_missing_icon`, `on_missing_icon` hook, `fallback_icons`)
13
+ and per-process SVG render cache (`cache_svgs`).
14
+ - RuboCop plugin (lint_roller) with `Glyphs/LegacyIconHelper`, `Glyphs/IconResolution`, and
15
+ `Glyphs/PreferLibraryComponent`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 David Alejandro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # Glyphs
2
+
3
+ Phlex icon components for every [rails_icons](https://github.com/rails-designer/rails_icons) library —
4
+ plus bundled RuboCop cops that validate icon names at lint time and autocorrect legacy icon helpers.
5
+
6
+ ```ruby
7
+ class ApplicationComponent < Phlex::HTML
8
+ include Glyphs
9
+ end
10
+
11
+ LucideIcon(:house, class: "size-4")
12
+ PhosphorIcon("lock", variant: :bold)
13
+ HeroIcon(:check, class: "size-5 text-success")
14
+ ```
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem "glyphs"
21
+ ```
22
+
23
+ Glyphs renders the SVG files that rails_icons syncs into your app. If you haven't already:
24
+
25
+ ```bash
26
+ rails generate rails_icons:install --libraries=lucide heroicons
27
+ rails generate rails_icons:sync --libraries=lucide heroicons
28
+ ```
29
+
30
+ Outside Rails, configure the [icons](https://rubygems.org/gems/icons) gem directly:
31
+
32
+ ```ruby
33
+ Icons.configure do |config|
34
+ config.base_path = File.expand_path(__dir__)
35
+ config.icons_path = "svg/icons"
36
+ end
37
+ ```
38
+
39
+ ## Components
40
+
41
+ `include Glyphs` (a `Phlex::Kit`) into your component base class and call icons as capitalized methods:
42
+
43
+ | Component | rails_icons library |
44
+ |---|---|
45
+ | `LucideIcon` | lucide |
46
+ | `PhosphorIcon` | phosphor |
47
+ | `HeroIcon` | heroicons |
48
+ | `TablerIcon` | tabler |
49
+ | `FeatherIcon` | feather |
50
+ | `BoxIcon` | boxicons |
51
+ | `FlagIcon` | flags |
52
+ | `HugeIcon` | hugeicons |
53
+ | `LinearIcon` | linear |
54
+ | `RadixIcon` | radix |
55
+ | `SidekickIcon` | sidekickicons |
56
+ | `WeatherIcon` | weather |
57
+ | `AnimatedIcon` | animated (bundled spinners: `faded-spinner`, `bouncing-dots`, ...) |
58
+
59
+ Names can be symbols or strings; underscores are dasherized (`:circle_check` → `circle-check.svg`).
60
+ `variant:` selects the library variant (default comes from your rails_icons/icons configuration);
61
+ every other keyword (`class:`, `data:`, `stroke_width:`, ...) is forwarded onto the `<svg>` tag.
62
+
63
+ The generic component requires an explicit library:
64
+
65
+ ```ruby
66
+ Icon(:house, library: :lucide) # flagged by Glyphs/PreferLibraryComponent, prefer LucideIcon(:house)
67
+ ```
68
+
69
+ Custom libraries get first-class components too:
70
+
71
+ ```ruby
72
+ Glyphs.register_library(:brand, component: :BrandIcon)
73
+ BrandIcon(:logo)
74
+ ```
75
+
76
+ (The SVG location for a custom library is configured through rails_icons/icons custom-library config.)
77
+
78
+ ## Missing-icon policy
79
+
80
+ ```ruby
81
+ # config/initializers/glyphs.rb
82
+ Glyphs.configure do |config|
83
+ # Re-raise Icons::IconNotFound? Default: true locally (dev/test), never blind in production.
84
+ config.raise_on_missing_icon = -> { Rails.env.local? }
85
+
86
+ # Instrumentation hook, called before the fallback renders.
87
+ config.on_missing_icon = lambda do |error, name:, library:, variant:|
88
+ Rails.logger.error("Icon missing: #{library}/#{variant}/#{name} (#{error.message})")
89
+ end
90
+
91
+ # Rendered instead of the missing icon (per library). nil/absent => re-raise.
92
+ config.fallback_icons = {
93
+ lucide: "circle-question-mark",
94
+ phosphor: "question",
95
+ heroicons: "question-mark-circle"
96
+ }
97
+
98
+ # Memoize rendered SVG strings per [library, variant, name, attributes]. Default: true.
99
+ # Note: fallback renders are cached under the original name, so on_missing_icon
100
+ # fires once per process per missing icon rather than on every render.
101
+ config.cache_svgs = true
102
+ end
103
+ ```
104
+
105
+ ## RuboCop cops
106
+
107
+ Add the plugin (RuboCop >= 1.72):
108
+
109
+ ```yaml
110
+ # .rubocop.yml
111
+ plugins:
112
+ - glyphs
113
+ ```
114
+
115
+ (Classic `require: [glyphs/rubocop]` also works, but you must enable the cops yourself.)
116
+
117
+ ### Glyphs/LegacyIconHelper
118
+
119
+ Autocorrects legacy helper calls to Glyphs components:
120
+
121
+ ```ruby
122
+ _lucide(:house, class: "size-4") # => LucideIcon(:house, class: "size-4")
123
+ _heroicon(:check) # => HeroIcon(:check)
124
+ icon("check", library: "lucide") # => LucideIcon("check")
125
+ icon("check") # => HeroIcon("check") (DefaultLibraryComponent)
126
+ ```
127
+
128
+ ```yaml
129
+ Glyphs/LegacyIconHelper:
130
+ Include:
131
+ - app/**/*.rb
132
+ DefaultLibraryComponent: HeroIcon
133
+ # Mappings replaces the built-in defaults (_lucide/_phosphor/_hero/_heroicon/_tabler):
134
+ # Mappings:
135
+ # _custom: CustomIcon
136
+ ```
137
+
138
+ ### Glyphs/IconResolution
139
+
140
+ Validates statically-known icon names against your synced SVG directories, honors literal
141
+ `variant:` keywords, suggests close matches (with autocorrect for unambiguous typos and the
142
+ Lucide v1 renames), and flags raw `iconify` class strings:
143
+
144
+ ```ruby
145
+ LucideIcon(:alert_triangle) # => corrected to LucideIcon(:triangle_alert)
146
+ span(class: "iconify lucide--house size-4") # => corrected to LucideIcon(:house, class: "size-4")
147
+ ```
148
+
149
+ ```yaml
150
+ Glyphs/IconResolution:
151
+ Include:
152
+ - app/**/*.rb
153
+ IconsPath: app/assets/svg/icons
154
+ # Libraries merges over the built-in defaults (component => Dir/DefaultVariant):
155
+ Libraries:
156
+ PhosphorIcon: { Dir: phosphor, DefaultVariant: regular }
157
+ ```
158
+
159
+ ### Glyphs/PreferLibraryComponent
160
+
161
+ ```ruby
162
+ Icon(:house, library: :lucide) # => LucideIcon(:house)
163
+ ```
164
+
165
+ ## Migrating an app off icon helpers
166
+
167
+ 1. Add `gem "glyphs"`, `include Glyphs` where the helpers used to be included.
168
+ 2. Add the plugin and cop config above to `.rubocop.yml`.
169
+ 3. Delete your local icon helper, then run:
170
+
171
+ ```bash
172
+ bundle exec rubocop -A --only Glyphs/LegacyIconHelper,Glyphs/PreferLibraryComponent
173
+ ```
174
+
175
+ 4. Wire your old missing-icon logging into `Glyphs.configure` (see above).
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ bundle install
181
+ bundle exec rspec
182
+ bundle exec rubocop
183
+ ```
184
+
185
+ ## License
186
+
187
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,25 @@
1
+ Glyphs/LegacyIconHelper:
2
+ Description: 'Replace legacy icon helpers (_lucide, _hero, icon(), ...) with Glyphs components.'
3
+ Enabled: true
4
+ VersionAdded: '0.1.0'
5
+ SafeAutoCorrect: true
6
+ # Mappings replaces the built-in defaults when set:
7
+ # Mappings:
8
+ # _lucide: LucideIcon
9
+ # _heroicon: HeroIcon
10
+ DefaultLibraryComponent: HeroIcon
11
+
12
+ Glyphs/IconResolution:
13
+ Description: 'Validate that statically-known icon names exist in the synced SVG directories.'
14
+ Enabled: true
15
+ VersionAdded: '0.1.0'
16
+ IconsPath: app/assets/svg/icons
17
+ # Libraries merges over the built-in defaults:
18
+ # Libraries:
19
+ # PhosphorIcon: { Dir: phosphor, DefaultVariant: light }
20
+
21
+ Glyphs/PreferLibraryComponent:
22
+ Description: 'Prefer library-specific components over generic Icon(..., library: ...).'
23
+ Enabled: true
24
+ VersionAdded: '0.1.0'
25
+ SafeAutoCorrect: true
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class AnimatedIcon < Icon
5
+ LIBRARY = :animated
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class BoxIcon < Icon
5
+ LIBRARY = :boxicons
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class Configuration
5
+ DEFAULT_FALLBACK_ICONS = {
6
+ lucide: "circle-question-mark",
7
+ phosphor: "question",
8
+ heroicons: "question-mark-circle"
9
+ }.freeze
10
+
11
+ # raise_on_missing_icon: callable -> bool; when true, Icons::IconNotFound is re-raised.
12
+ # on_missing_icon: callable(error, name:, library:, variant:) instrumentation hook.
13
+ # fallback_icons: { library => icon_name } rendered instead of a missing icon.
14
+ # cache_svgs: memoize rendered SVG strings per [library, variant, name, attributes].
15
+ attr_accessor :raise_on_missing_icon, :on_missing_icon, :fallback_icons, :cache_svgs
16
+
17
+ def initialize
18
+ @raise_on_missing_icon = -> { defined?(Rails) ? Rails.env.local? : true }
19
+ @on_missing_icon = ->(error, name:, library:, variant:) {}
20
+ @fallback_icons = DEFAULT_FALLBACK_ICONS.dup
21
+ @cache_svgs = true
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class FeatherIcon < Icon
5
+ LIBRARY = :feather
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class FlagIcon < Icon
5
+ LIBRARY = :flags
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class HeroIcon < Icon
5
+ LIBRARY = :heroicons
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class HugeIcon < Icon
5
+ LIBRARY = :hugeicons
6
+ end
7
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # Base icon component. Prefer the library-specific subclasses (LucideIcon,
5
+ # HeroIcon, ...); the generic form requires an explicit library:
6
+ #
7
+ # Icon(:house, library: :lucide)
8
+ class Icon < Phlex::HTML
9
+ LIBRARY = nil
10
+
11
+ def initialize(name, variant: nil, library: self.class::LIBRARY, **attributes)
12
+ if library.nil?
13
+ raise ArgumentError,
14
+ "library is required — use a library component (LucideIcon, HeroIcon, ...) or pass library:"
15
+ end
16
+
17
+ @name = name.to_s.tr("_", "-")
18
+ @library = library.to_sym
19
+ @variant = variant&.to_sym
20
+ @attributes = attributes
21
+
22
+ super()
23
+ end
24
+
25
+ def view_template
26
+ raw safe(svg_markup)
27
+ end
28
+
29
+ private
30
+
31
+ def svg_markup
32
+ Glyphs.svg_for(name: @name, library: @library, variant: @variant, attributes: @attributes)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class LinearIcon < Icon
5
+ LIBRARY = :linear
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class LucideIcon < Icon
5
+ LIBRARY = :lucide
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class PhosphorIcon < Icon
5
+ LIBRARY = :phosphor
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class RadixIcon < Icon
5
+ LIBRARY = :radix
6
+ end
7
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+ require "lint_roller"
5
+ require "pathname"
6
+ require "yaml"
7
+
8
+ require_relative "version"
9
+ require_relative "../rubocop/cop/glyphs/library_call_helpers"
10
+ require_relative "../rubocop/cop/glyphs/legacy_icon_helper"
11
+ require_relative "../rubocop/cop/glyphs/icon_resolution"
12
+ require_relative "../rubocop/cop/glyphs/prefer_library_component"
13
+
14
+ module Glyphs
15
+ module RuboCop
16
+ class Plugin < LintRoller::Plugin
17
+ def about
18
+ LintRoller::About.new(
19
+ name: "glyphs",
20
+ version: Glyphs::VERSION,
21
+ homepage: "https://github.com/mhenrixon/glyphs",
22
+ description: "Icon-name validation and legacy icon-helper autocorrection for Glyphs components."
23
+ )
24
+ end
25
+
26
+ def supported?(context)
27
+ context.engine == :rubocop
28
+ end
29
+
30
+ def rules(_context)
31
+ LintRoller::Rules.new(
32
+ type: :path,
33
+ config_format: :rubocop,
34
+ value: Pathname.new(__dir__).join("../../config/default.yml")
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class SidekickIcon < Icon
5
+ LIBRARY = :sidekickicons
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class TablerIcon < Icon
5
+ LIBRARY = :tabler
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ class WeatherIcon < Icon
5
+ LIBRARY = :weather
6
+ end
7
+ end
data/lib/glyphs.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+ require "icons"
5
+ require "zeitwerk"
6
+ require_relative "glyphs/version"
7
+
8
+ loader = Zeitwerk::Loader.for_gem
9
+ loader.ignore("#{__dir__}/glyphs/version.rb")
10
+ # The RuboCop plugin lives in a foreign namespace and is only loaded inside a
11
+ # RuboCop process (see the bottom of this file).
12
+ loader.ignore("#{__dir__}/glyphs/rubocop.rb")
13
+ loader.ignore("#{__dir__}/rubocop")
14
+ loader.setup
15
+
16
+ # Ensure the icons gem is configured even outside Rails. Inside Rails the
17
+ # rails_icons engine calls `Icons.configure` with the app's settings; the
18
+ # configuration is memoized, so this never clobbers app configuration.
19
+ Icons.config
20
+
21
+ # Phlex icon components for every rails_icons library, exposed as a Phlex::Kit:
22
+ #
23
+ # include Glyphs
24
+ # LucideIcon(:house, class: "size-4")
25
+ module Glyphs
26
+ extend Phlex::Kit
27
+
28
+ @svg_cache = {}
29
+ @svg_cache_mutex = Mutex.new
30
+
31
+ class << self
32
+ def configure
33
+ yield(configuration) if block_given?
34
+ configuration
35
+ end
36
+
37
+ def configuration
38
+ @configuration ||= Configuration.new
39
+ end
40
+
41
+ def reset_configuration!
42
+ @configuration = Configuration.new
43
+ end
44
+
45
+ # Defines a `Glyphs::Icon` subclass for a custom icon library and exposes it
46
+ # as a kit method. The library's SVG location itself is configured through
47
+ # the icons/rails_icons custom-library configuration.
48
+ #
49
+ # Glyphs.register_library(:brand, component: :BrandIcon)
50
+ # # => BrandIcon(:logo, class: "size-4")
51
+ def register_library(library, component:)
52
+ library = library.to_sym
53
+ name = component.to_sym
54
+
55
+ if const_defined?(name, false)
56
+ existing = const_get(name, false)
57
+ return existing if existing < Icon && library == existing::LIBRARY
58
+
59
+ raise ArgumentError, "Glyphs::#{name} is already defined and does not render the #{library} library"
60
+ end
61
+
62
+ klass = Class.new(Icon)
63
+ klass.const_set(:LIBRARY, library)
64
+ const_set(name, klass)
65
+ end
66
+
67
+ # Returns the SVG markup for an icon, applying the configured missing-icon
68
+ # policy (raise / instrument / fallback) and an optional per-process cache.
69
+ def svg_for(name:, library:, variant:, attributes:)
70
+ return render_svg(name:, library:, variant:, attributes:) unless configuration.cache_svgs
71
+
72
+ key = [library, variant, name, attributes]
73
+ @svg_cache_mutex.synchronize do
74
+ @svg_cache[key] ||= render_svg(name:, library:, variant:, attributes:)
75
+ end
76
+ end
77
+
78
+ def reset_cache!
79
+ @svg_cache_mutex.synchronize { @svg_cache = {} }
80
+ end
81
+
82
+ private
83
+
84
+ def render_svg(name:, library:, variant:, attributes:)
85
+ build_svg(name:, library:, variant:, attributes:)
86
+ rescue Icons::IconNotFound => e
87
+ handle_missing_icon(e, name:, library:, variant:, attributes:)
88
+ end
89
+
90
+ def build_svg(name:, library:, variant:, attributes:)
91
+ Icons::Icon.new(name:, library:, variant:, arguments: attributes).svg
92
+ end
93
+
94
+ def handle_missing_icon(error, name:, library:, variant:, attributes:)
95
+ raise error if configuration.raise_on_missing_icon.call
96
+
97
+ configuration.on_missing_icon.call(error, name:, library:, variant:)
98
+
99
+ fallback = configuration.fallback_icons[library.to_sym]
100
+ raise error if fallback.nil?
101
+
102
+ begin
103
+ build_svg(name: fallback, library:, variant:, attributes:)
104
+ rescue Icons::IconNotFound
105
+ raise error
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ # Make the RuboCop plugin resolvable lazily: RuboCop constantizes
112
+ # Glyphs::RuboCop::Plugin (from the gemspec's default_lint_roller_plugin
113
+ # metadata) when it loads plugins, which can happen long after this gem was
114
+ # required — e.g. app boots first, cop specs load RuboCop later.
115
+ Glyphs.autoload :RuboCop, File.expand_path("glyphs/rubocop", __dir__)
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Glyphs
6
+ # Validates that statically-known icon names exist in the synced SVG
7
+ # directories (`IconsPath`), suggests close matches for typos, and flags
8
+ # raw `iconify` class strings.
9
+ #
10
+ # Both Glyphs component calls (`LucideIcon(:house)`) and legacy helper
11
+ # calls (`_lucide(:house)`) are validated. A literal `variant:` keyword is
12
+ # honored; calls with dynamic names or variants are skipped.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # LucideIcon(:non_existent_icon)
17
+ # span(class: "iconify lucide--house size-4")
18
+ #
19
+ # # good
20
+ # LucideIcon(:house, class: "size-4")
21
+ class IconResolution < Base
22
+ extend AutoCorrector
23
+
24
+ DEFAULT_LIBRARIES = {
25
+ "LucideIcon" => { "Dir" => "lucide", "DefaultVariant" => "outline" },
26
+ "PhosphorIcon" => { "Dir" => "phosphor", "DefaultVariant" => "regular" },
27
+ "HeroIcon" => { "Dir" => "heroicons", "DefaultVariant" => "outline" },
28
+ "TablerIcon" => { "Dir" => "tabler", "DefaultVariant" => "outline" },
29
+ "SidekickIcon" => { "Dir" => "sidekickicons", "DefaultVariant" => "outline" }
30
+ }.freeze
31
+
32
+ LEGACY_HELPERS = {
33
+ "_lucide" => "LucideIcon",
34
+ "_phosphor" => "PhosphorIcon",
35
+ "_hero" => "HeroIcon",
36
+ "_heroicon" => "HeroIcon",
37
+ "_tabler" => "TablerIcon"
38
+ }.freeze
39
+
40
+ ICONIFY_PREFIX_TO_COMPONENT = {
41
+ "lucide" => "LucideIcon",
42
+ "phosphor" => "PhosphorIcon",
43
+ "heroicons" => "HeroIcon"
44
+ }.freeze
45
+
46
+ KNOWN_LUCIDE_RENAMES = {
47
+ "alert-triangle" => "triangle-alert",
48
+ "alert-circle" => "circle-alert",
49
+ "x-circle" => "circle-x"
50
+ }.freeze
51
+
52
+ ICONIFY_PATTERN = /\biconify\s+(lucide|phosphor|heroicons)--([a-z0-9-]+)/
53
+
54
+ RAW_MSG = "Use `%{component}(:%{symbol}, class: \"%{rest}\")` instead of raw `iconify %{library}--…` class."
55
+ RAW_DSTR_MSG = "Use `LucideIcon(name, class: ...)` etc. instead of building a raw " \
56
+ "`iconify <library>--…` class string."
57
+ MISSING_MSG = "Icon `%{name}` not found in %{library}/%{variant}. %{suggestion}"
58
+
59
+ def on_str(node)
60
+ return if node.parent&.dstr_type?
61
+
62
+ check_iconify_string(node, node.value)
63
+ end
64
+
65
+ def on_dstr(node)
66
+ combined = node.children.filter_map { |child| child.str_type? ? child.value : "INTERPOLATED" }.join
67
+ return unless combined.include?("iconify ")
68
+
69
+ add_offense(node, message: RAW_DSTR_MSG)
70
+ end
71
+
72
+ def on_send(node)
73
+ return unless node.receiver.nil?
74
+
75
+ component = component_for(node.method_name)
76
+ return unless component
77
+
78
+ library = libraries[component]
79
+ return unless library
80
+
81
+ check_call(node, component, library)
82
+ rescue StandardError => e
83
+ source = node.location.expression.source_buffer.name
84
+ warn "[Glyphs/IconResolution] suppressed #{e.class}: #{e.message} at #{source}:#{node.first_line}"
85
+ end
86
+
87
+ private
88
+
89
+ def component_for(method_name)
90
+ name = method_name.to_s
91
+ return name if libraries.key?(name)
92
+
93
+ LEGACY_HELPERS[name]
94
+ end
95
+
96
+ def libraries
97
+ @libraries ||= DEFAULT_LIBRARIES.merge(cop_config["Libraries"] || {})
98
+ end
99
+
100
+ def check_call(node, _component, library)
101
+ name = literal_name(node.first_argument)
102
+ return unless name
103
+
104
+ variant = variant_for(node, library)
105
+ return if variant == :dynamic
106
+
107
+ available = available_icons(library["Dir"], variant)
108
+ return if available.empty?
109
+ return if available.include?(name)
110
+
111
+ suggestion = build_suggestion(name, available, library["Dir"])
112
+ add_offense(node.first_argument,
113
+ message: format(MISSING_MSG, name:, library: library["Dir"], variant:,
114
+ suggestion: suggestion[:msg])) do |corrector|
115
+ next unless suggestion[:autocorrect]
116
+
117
+ replacement =
118
+ if node.first_argument.sym_type?
119
+ ":#{suggestion[:name].tr('-', '_')}"
120
+ else
121
+ %("#{suggestion[:name]}")
122
+ end
123
+ corrector.replace(node.first_argument, replacement)
124
+ end
125
+ end
126
+
127
+ def variant_for(node, library)
128
+ pair = variant_pair(node)
129
+ return library["DefaultVariant"] if pair.nil?
130
+ return :dynamic unless pair.value.str_type? || pair.value.sym_type?
131
+
132
+ pair.value.value.to_s
133
+ end
134
+
135
+ def variant_pair(node)
136
+ hash = node.arguments.last
137
+ return nil unless hash&.hash_type?
138
+
139
+ hash.pairs.find { |pair| pair.key.sym_type? && pair.key.value == :variant }
140
+ end
141
+
142
+ def literal_name(node)
143
+ return unless node && (node.sym_type? || node.str_type?)
144
+
145
+ node.value.to_s.tr("_", "-")
146
+ end
147
+
148
+ def check_iconify_string(node, value)
149
+ return unless value.include?("iconify ")
150
+ return unless (match = ICONIFY_PATTERN.match(value))
151
+
152
+ iconify_prefix = match[1]
153
+ icon_name = match[2]
154
+ rest = remaining_classes(value, iconify_prefix, icon_name)
155
+ symbol = icon_name.tr("-", "_")
156
+ component = ICONIFY_PREFIX_TO_COMPONENT.fetch(iconify_prefix)
157
+
158
+ add_offense(node,
159
+ message: format(RAW_MSG, component:, library: iconify_prefix, symbol:, rest:)) do |corrector|
160
+ replacement = if rest.empty?
161
+ "#{component}(:#{symbol})"
162
+ else
163
+ "#{component}(:#{symbol}, class: \"#{rest}\")"
164
+ end
165
+
166
+ span_node = enclosing_span_call(node)
167
+ corrector.replace(span_node, replacement) if span_node
168
+ end
169
+ end
170
+
171
+ def enclosing_span_call(str_node)
172
+ pair = str_node.parent
173
+ return nil unless pair&.pair_type?
174
+ return nil unless pair.key.sym_type? && pair.key.value == :class
175
+
176
+ hash = pair.parent
177
+ return nil unless hash&.hash_type?
178
+ return nil if hash.pairs.size != 1
179
+
180
+ send_node = hash.parent
181
+ return nil unless send_node&.send_type?
182
+ return nil unless send_node.method_name == :span
183
+ return nil unless send_node.arguments.size == 1
184
+
185
+ send_node
186
+ end
187
+
188
+ def remaining_classes(value, library, icon_name)
189
+ value.sub(/iconify\s+#{library}--#{Regexp.escape(icon_name)}\s*/, "").strip
190
+ end
191
+
192
+ def build_suggestion(name, available, library_dir)
193
+ if library_dir == "lucide" && (renamed = KNOWN_LUCIDE_RENAMES[name]) && available.include?(renamed)
194
+ return { msg: "Did you mean `:#{renamed.tr('-', '_')}`? (Lucide v1 reordered prefix and suffix.)",
195
+ autocorrect: true,
196
+ name: renamed }
197
+ end
198
+
199
+ matches = fuzzy_match(name, available)
200
+ if matches.size == 1
201
+ { msg: "Did you mean `:#{matches.first.tr('-', '_')}`?", autocorrect: true, name: matches.first }
202
+ elsif matches.any?
203
+ list = matches.first(5).map { |match| "`:#{match.tr('-', '_')}`" }.join(", ")
204
+ { msg: "Did you mean one of: #{list}?", autocorrect: false, name: nil }
205
+ else
206
+ { msg: "No similar icons found.", autocorrect: false, name: nil }
207
+ end
208
+ end
209
+
210
+ def fuzzy_match(name, available)
211
+ name_parts = name.split("-")
212
+
213
+ scored = available.filter_map do |candidate|
214
+ score = part_score(name_parts, candidate.split("-"))
215
+ distance = damerau_levenshtein(name, candidate)
216
+ score += [3 - distance, 0].max if distance <= 2
217
+ score += 2 if name.length >= 3 && candidate.include?(name)
218
+ score += 2 if name.length >= 3 && candidate.split("-").any? { |part| part.start_with?(name[0..2]) }
219
+
220
+ [candidate, score] if score >= 3
221
+ end
222
+
223
+ scored.sort_by { |_, score| -score }.map(&:first).take(5)
224
+ end
225
+
226
+ def part_score(name_parts, candidate_parts)
227
+ score = 0
228
+ score += 5 if name_parts.sort == candidate_parts.sort
229
+ score += (name_parts & candidate_parts).size * 2
230
+ score += 4 if name_parts.size == 2 && candidate_parts.size == 2 && name_parts.reverse == candidate_parts
231
+ score
232
+ end
233
+
234
+ def damerau_levenshtein(left, right)
235
+ return right.length if left.empty?
236
+ return left.length if right.empty?
237
+ return 99 if (left.length - right.length).abs > 3
238
+
239
+ rows = Array.new(left.length + 1) { Array.new(right.length + 1, 0) }
240
+ (0..left.length).each { |i| rows[i][0] = i }
241
+ (0..right.length).each { |j| rows[0][j] = j }
242
+
243
+ (1..left.length).each do |i|
244
+ (1..right.length).each do |j|
245
+ cost = left[i - 1] == right[j - 1] ? 0 : 1
246
+ rows[i][j] = [
247
+ rows[i - 1][j] + 1,
248
+ rows[i][j - 1] + 1,
249
+ rows[i - 1][j - 1] + cost
250
+ ].min
251
+ if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1]
252
+ rows[i][j] = [rows[i][j], rows[i - 2][j - 2] + 1].min
253
+ end
254
+ end
255
+ end
256
+
257
+ rows[left.length][right.length]
258
+ end
259
+
260
+ def icons_base_path
261
+ @icons_base_path ||= File.expand_path(cop_config["IconsPath"] || "app/assets/svg/icons", Dir.pwd)
262
+ end
263
+
264
+ def available_icons(library_dir, variant)
265
+ self.class.available_icons_for(icons_base_path, library_dir, variant)
266
+ end
267
+
268
+ class << self
269
+ def available_icons_for(base_path, library_dir, variant)
270
+ @available_icons_cache ||= {}
271
+ @available_icons_cache[[base_path, library_dir, variant]] ||= load_icons(base_path, library_dir, variant)
272
+ end
273
+
274
+ private
275
+
276
+ def load_icons(base_path, library_dir, variant)
277
+ path = File.join(*[base_path, library_dir, variant].compact.reject { |part| part.to_s.empty? })
278
+ return [] unless Dir.exist?(path)
279
+
280
+ Dir.children(path).filter_map { |file| File.basename(file, ".svg") if file.end_with?(".svg") }.sort
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Glyphs
6
+ # Replaces legacy icon helper calls with Glyphs kit components.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # _lucide(:house, class: "size-4")
11
+ # icon("check", library: "lucide")
12
+ # icon("check")
13
+ #
14
+ # # good
15
+ # LucideIcon(:house, class: "size-4")
16
+ # LucideIcon("check")
17
+ # HeroIcon("check") # `DefaultLibraryComponent` when no library: is given
18
+ class LegacyIconHelper < Base
19
+ include RangeHelp
20
+ include LibraryCallHelpers
21
+ extend AutoCorrector
22
+
23
+ MSG = "Use `%{component}(...)` instead of `%{helper}(...)`."
24
+ MSG_DYNAMIC = "Use a Glyphs component (e.g. `LucideIcon(...)`) instead of `icon(...)` with a dynamic library."
25
+ MSG_UNKNOWN = "Use a Glyphs component instead of `icon(...)`; add a `LibraryComponents` mapping " \
26
+ "for library `%{library}`."
27
+
28
+ DEFAULT_MAPPINGS = {
29
+ _lucide: "LucideIcon",
30
+ _phosphor: "PhosphorIcon",
31
+ _hero: "HeroIcon",
32
+ _heroicon: "HeroIcon",
33
+ _tabler: "TablerIcon"
34
+ }.freeze
35
+
36
+ def on_send(node)
37
+ return unless node.receiver.nil?
38
+
39
+ if (component = mappings[node.method_name])
40
+ rename_offense(node, component)
41
+ elsif node.method_name == :icon && node.arguments.any?
42
+ correct_icon_call(node)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def rename_offense(node, component)
49
+ message = format(MSG, component:, helper: node.method_name)
50
+ add_offense(node.loc.selector, message:) do |corrector|
51
+ corrector.replace(node.loc.selector, component)
52
+ end
53
+ end
54
+
55
+ def correct_icon_call(node)
56
+ return if node.first_argument.hash_type? # no name argument; not an icon render call
57
+
58
+ pair = library_pair(node)
59
+ if pair.nil?
60
+ rename_offense(node, default_component)
61
+ elsif literal_pair?(pair)
62
+ correct_icon_with_library(node, pair)
63
+ else
64
+ add_offense(node.loc.selector, message: MSG_DYNAMIC)
65
+ end
66
+ end
67
+
68
+ def correct_icon_with_library(node, pair)
69
+ library = pair.value.value.to_s
70
+ component = library_components[library]
71
+
72
+ if component.nil?
73
+ add_offense(node.loc.selector, message: format(MSG_UNKNOWN, library:))
74
+ return
75
+ end
76
+
77
+ message = format(MSG, component:, helper: :icon)
78
+ add_offense(node.loc.selector, message:) do |corrector|
79
+ corrector.replace(node.loc.selector, component)
80
+ remove_library_pair(corrector, pair)
81
+ end
82
+ end
83
+
84
+ def mappings
85
+ @mappings ||= begin
86
+ configured = cop_config["Mappings"] || {}
87
+ configured.empty? ? DEFAULT_MAPPINGS : configured.to_h { |key, value| [key.to_sym, value.to_s] }
88
+ end
89
+ end
90
+
91
+ def default_component
92
+ cop_config["DefaultLibraryComponent"] || "HeroIcon"
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Glyphs
6
+ # Shared logic for cops that rewrite icon calls: locating a literal
7
+ # `library:`/`from:` pair, mapping libraries to Glyphs components, and
8
+ # removing the pair without leaving dangling commas.
9
+ module LibraryCallHelpers
10
+ LIBRARY_KEYS = %i[library from].freeze
11
+
12
+ LIBRARY_TO_COMPONENT = {
13
+ "lucide" => "LucideIcon",
14
+ "phosphor" => "PhosphorIcon",
15
+ "heroicons" => "HeroIcon",
16
+ "tabler" => "TablerIcon",
17
+ "feather" => "FeatherIcon",
18
+ "boxicons" => "BoxIcon",
19
+ "flags" => "FlagIcon",
20
+ "hugeicons" => "HugeIcon",
21
+ "linear" => "LinearIcon",
22
+ "radix" => "RadixIcon",
23
+ "sidekickicons" => "SidekickIcon",
24
+ "weather" => "WeatherIcon",
25
+ "animated" => "AnimatedIcon"
26
+ }.freeze
27
+
28
+ private
29
+
30
+ def library_pair(node)
31
+ hash = node.arguments.last
32
+ return nil unless hash&.hash_type?
33
+
34
+ hash.pairs.find { |pair| pair.key.sym_type? && LIBRARY_KEYS.include?(pair.key.value) }
35
+ end
36
+
37
+ def literal_pair?(pair)
38
+ pair.value.str_type? || pair.value.sym_type?
39
+ end
40
+
41
+ def library_components
42
+ @library_components ||= LIBRARY_TO_COMPONENT.merge(cop_config["LibraryComponents"] || {})
43
+ end
44
+
45
+ def remove_library_pair(corrector, pair)
46
+ hash = pair.parent
47
+ range = hash.pairs.one? ? hash.source_range : pair.source_range
48
+ corrector.remove(
49
+ range_with_surrounding_comma(range_with_surrounding_space(range, side: :left), :left)
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Glyphs
6
+ # Prefers library-specific components over the generic `Icon(...)` kit
7
+ # call with a `library:` argument.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # Icon(:house, library: :lucide)
12
+ #
13
+ # # good
14
+ # LucideIcon(:house)
15
+ class PreferLibraryComponent < Base
16
+ include RangeHelp
17
+ include LibraryCallHelpers
18
+ extend AutoCorrector
19
+
20
+ MSG = "Use `%{component}(...)` instead of `Icon(..., library: ...)`."
21
+ MSG_DYNAMIC = "Prefer a library-specific Glyphs component over `Icon(...)` with a dynamic library."
22
+
23
+ def on_send(node)
24
+ return unless node.receiver.nil? && node.method_name == :Icon && node.arguments.any?
25
+
26
+ pair = library_pair(node)
27
+ return unless pair
28
+
29
+ unless literal_pair?(pair)
30
+ add_offense(node.loc.selector, message: MSG_DYNAMIC)
31
+ return
32
+ end
33
+
34
+ component = library_components[pair.value.value.to_s]
35
+ return unless component
36
+
37
+ message = format(MSG, component:)
38
+ add_offense(node.loc.selector, message:) do |corrector|
39
+ corrector.replace(node.loc.selector, component)
40
+ remove_library_pair(corrector, pair)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: glyphs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mikael Henriksson
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: phlex
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails_icons
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: zeitwerk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.6'
54
+ description: Glyphs renders SVG icons as Phlex components (LucideIcon, PhosphorIcon,
55
+ HeroIcon, ...) from rails_icons-synced icon sets, with configurable missing-icon
56
+ handling and bundled RuboCop cops that validate icon names and autocorrect legacy
57
+ icon helpers.
58
+ email: mikael@zoolutions.llc
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE.txt
65
+ - README.md
66
+ - config/default.yml
67
+ - lib/glyphs.rb
68
+ - lib/glyphs/animated_icon.rb
69
+ - lib/glyphs/box_icon.rb
70
+ - lib/glyphs/configuration.rb
71
+ - lib/glyphs/feather_icon.rb
72
+ - lib/glyphs/flag_icon.rb
73
+ - lib/glyphs/hero_icon.rb
74
+ - lib/glyphs/huge_icon.rb
75
+ - lib/glyphs/icon.rb
76
+ - lib/glyphs/linear_icon.rb
77
+ - lib/glyphs/lucide_icon.rb
78
+ - lib/glyphs/phosphor_icon.rb
79
+ - lib/glyphs/radix_icon.rb
80
+ - lib/glyphs/rubocop.rb
81
+ - lib/glyphs/sidekick_icon.rb
82
+ - lib/glyphs/tabler_icon.rb
83
+ - lib/glyphs/version.rb
84
+ - lib/glyphs/weather_icon.rb
85
+ - lib/rubocop/cop/glyphs/icon_resolution.rb
86
+ - lib/rubocop/cop/glyphs/legacy_icon_helper.rb
87
+ - lib/rubocop/cop/glyphs/library_call_helpers.rb
88
+ - lib/rubocop/cop/glyphs/prefer_library_component.rb
89
+ homepage: https://github.com/mhenrixon/glyphs
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ source_code_uri: https://github.com/mhenrixon/glyphs
94
+ changelog_uri: https://github.com/mhenrixon/glyphs/blob/main/CHANGELOG.md
95
+ bug_tracker_uri: https://github.com/mhenrixon/glyphs/issues
96
+ default_lint_roller_plugin: Glyphs::RuboCop::Plugin
97
+ rubygems_mfa_required: 'true'
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '3.2'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 4.0.9
113
+ specification_version: 4
114
+ summary: Phlex icon components for every rails_icons library
115
+ test_files: []