glyphs 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 977c4ff81bc073f486991e32e6f27418419d440ddf15fc0cd8cb4357091abe8a
4
- data.tar.gz: e2f6a73fa1545cae2d0714c9b2528b8b8d65b6474af45e1c73ae0999b5851cde
3
+ metadata.gz: 245c7101b5dad69bf73859eea7a5d7c9027f1573d4e1b4ccbcde2bed3e61266f
4
+ data.tar.gz: 585c169414705979b1c45bf02ad87de324c5e6a16fb6a8ddb0e631961820e533
5
5
  SHA512:
6
- metadata.gz: 3f9570e3186cf64e294a7ca0f4de342d5ef16ba7bd6ac86017737617566bef92caff25ea9f665a2d3044abe76990ec9ed81d020e0cd943bfc3983c4228f86360
7
- data.tar.gz: 49bd88b05cc3dfe3370348ade5a9498a26b6b7db12fb25fff63fb1ef0c8be94091cc71ef30728273a10a90be740d3fc0e867e751d5a505add4c1a1e0a0f741ce
6
+ metadata.gz: c5dfa80d58dbf2cc918f7a0ea2b74937156356137abca9c7297f0a974c1ee079176f335b6a5d33599f81f9d6af8f8efe324d9ab841f52126750bc80fb01abb38
7
+ data.tar.gz: 4a6b23ac1e990068f529e2ad7ed54fa078391256894d59b8a2390b5a9e773fd26a0dc55b11dcd3bfb00e13548791e80bcf1a5659bdd2ca82d26a5db1495893f1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+
7
+ - **Dynamic icon calls are now resolved from source.** `SourceScanner` no longer
8
+ silently skips `LucideIcon(some_var)` / `PhosphorIcon(tile[:icon])` — it harvests
9
+ the literal name from two places so the pruner keeps it:
10
+ - _file-scoped_: a file that dynamically renders a library keeps every
11
+ icon-name-shaped literal in that file for that library (ternaries, `case`,
12
+ locals);
13
+ - _declaration-based_: literals in icon-declaration positions anywhere (a hash
14
+ pair keyed `/icon/i`, or a constant named `/ICON/`) are kept for every
15
+ dynamically-rendered library, closing the cross-file gap (e.g. a notifier
16
+ `ICON = :bell` constant rendered from a view).
17
+
18
+ This makes `keep_icons` a last-resort escape hatch (DB/ENV/gem-chrome names)
19
+ rather than the primary mechanism. Only literals are harvested, so the scanner
20
+ never invents a reference.
21
+
3
22
  ## [0.2.0] - 2026-07-07
4
23
 
5
24
  ### Changed
data/README.md CHANGED
@@ -103,6 +103,82 @@ Glyphs.configure do |config|
103
103
  end
104
104
  ```
105
105
 
106
+ ## Shrinking icons in Docker
107
+
108
+ `rails g rails_icons:sync` copies a library's **entire** icon set into
109
+ `app/assets/svg/icons/<library>/<variant>/` — often thousands of SVGs, of which
110
+ an app uses a handful. `glyphs:prune_icons` deletes the unreferenced ones so a
111
+ Docker image ships only what it renders, while the committed repo keeps the full
112
+ set for development.
113
+
114
+ It scans your source (`app/**/*.rb`, `lib/**/*.rb` with a real parser, plus
115
+ `.erb/.haml/.slim` text) for `LucideIcon(:house)`, legacy helpers, generic
116
+ `Icon(:x, library: :lucide)`, and `iconify lucide--house` class strings, then
117
+ keeps that set **plus** two safety nets: the `keep_icons` allowlist and every
118
+ configured `fallback_icons` (a pruned fallback would 500 on the next missing
119
+ icon).
120
+
121
+ ### Dynamic calls are resolved automatically
122
+
123
+ Most apps render most icons dynamically — `LucideIcon(tile[:icon])`,
124
+ `PhosphorIcon(notification.icon)` — where the name lives in a `{ icon: :activity }`
125
+ hash, a `CHANNEL_ICONS` map, or an `ICON = :bell` constant, sometimes in a
126
+ different file from the render. A naïve static scan can't read those names and
127
+ would prune the icons they use.
128
+
129
+ The scanner resolves them from source, so you rarely need `keep_icons` at all:
130
+
131
+ - **File-scoped** — a file that renders a library dynamically (`LucideIcon(x)`)
132
+ keeps every icon-name-shaped literal in that file for that library. Catches
133
+ ternaries (`@open ? "caret-up" : "caret-down"`), `case/when`, and locals.
134
+ - **Declaration-based** — literals in icon-declaration positions *anywhere* — a
135
+ hash pair keyed like an icon (`icon: :gear`, `menu_icon: "house"`) or a
136
+ constant named like one (`ICON = :bell`, `STATUS_ICONS = { .. => :warning }`) —
137
+ are kept for every dynamically-rendered library, so a name declared in one
138
+ file and rendered from another survives.
139
+
140
+ Only literals are harvested, so the scanner never invents a reference; the
141
+ worst case is keeping a coincidentally icon-named string, which the post-prune
142
+ verification tolerates.
143
+
144
+ ### `keep_icons` — the last-resort escape hatch
145
+
146
+ For names a static scan genuinely can't see — read from a database, an ENV var,
147
+ or a gem's own chrome — list them explicitly:
148
+
149
+ ```ruby
150
+ # config/initializers/glyphs.rb
151
+ Glyphs.configure do |config|
152
+ # Flat list or per-library hash; names or fnmatch globs.
153
+ config.keep_icons = %w[menu palette search circle-*]
154
+ # config.keep_icons = { lucide: %w[menu palette], phosphor: %w[lock] }
155
+ end
156
+ ```
157
+
158
+ ```bash
159
+ # Preview (dry run — deletes nothing):
160
+ bin/rails glyphs:prune_icons
161
+
162
+ # Delete (both env vars required — a deliberate opt-in so it never fires
163
+ # accidentally on a developer's working tree):
164
+ PRUNE=1 GLYPHS_PRUNE_ICONS=1 bin/rails glyphs:prune_icons
165
+ ```
166
+
167
+ Run it in the image build, **after** `assets:precompile` and before the final
168
+ stage copies the app — so the deletions land in the image but never in a
169
+ developer's checkout:
170
+
171
+ ```dockerfile
172
+ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
173
+ RUN SECRET_KEY_BASE_DUMMY=1 PRUNE=1 GLYPHS_PRUNE_ICONS=1 ./bin/rails glyphs:prune_icons
174
+ ```
175
+
176
+ After deleting, the task **verifies** every kept icon still resolves and exits
177
+ non-zero if not — a bad prune (e.g. a missing allowlist entry) fails the build
178
+ instead of shipping broken icons. The bundled `animated` library and any
179
+ `custom_path` library are left untouched, and it refuses to empty a library
180
+ whose keep-set is empty.
181
+
106
182
  ## RuboCop cops
107
183
 
108
184
  Add the plugin (RuboCop >= 1.72):
@@ -14,13 +14,22 @@ module Glyphs
14
14
  # invoked before the fallback renders when not raising.
15
15
  # fallback_icons: { library => icon_name } rendered instead of a missing icon.
16
16
  # cache_svgs: memoize rendered SVG strings per [library, variant, name, attributes].
17
- attr_accessor :raise_on_missing, :on_missing_icon, :fallback_icons, :cache_svgs
17
+ # keep_icons: icons the pruner must keep despite no static reference
18
+ # (dynamic / data-driven names). A flat list of names/globs, or a
19
+ # { library => [names/globs] } hash. fallback_icons are always kept.
20
+ # prune_source_globs: extra locations the pruner's scanner text-scans IN
21
+ # ADDITION to the built-in Ruby/template defaults — e.g. a config file that
22
+ # names icons. Never replaces the defaults (nil = defaults only).
23
+ attr_accessor :raise_on_missing, :on_missing_icon, :fallback_icons, :cache_svgs,
24
+ :keep_icons, :prune_source_globs
18
25
 
19
26
  def initialize
20
27
  @raise_on_missing = defined?(Rails) ? Rails.env.local? : true
21
28
  @on_missing_icon = nil
22
29
  @fallback_icons = DEFAULT_FALLBACK_ICONS.dup
23
30
  @cache_svgs = true
31
+ @keep_icons = []
32
+ @prune_source_globs = nil
24
33
  end
25
34
  end
26
35
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Glyphs
6
+ # Deletes synced SVG icons that no source references, keeping the union of:
7
+ # scanned references, the configured keep_icons allowlist (names or globs,
8
+ # flat or per-library), and the configured fallback_icons (always — a pruned
9
+ # fallback would make every future missing-icon 500).
10
+ #
11
+ # Operates only on `icons_root` (`app/assets/svg/icons`), skips the bundled
12
+ # `animated` library, never deletes non-.svg files, and refuses to empty a
13
+ # library whose computed keep-set is empty (guards against a mis-scan wiping a
14
+ # whole library). Returns a PruneReport.
15
+ class IconPruner
16
+ ANIMATED = :animated
17
+
18
+ def initialize(icons_root:, references:, keep_icons: [], fallback_icons: {}, dry_run: false)
19
+ @icons_root = icons_root
20
+ @references = references
21
+ @keep_icons = keep_icons
22
+ @fallback_icons = fallback_icons
23
+ @dry_run = dry_run
24
+ end
25
+
26
+ def call
27
+ stats = []
28
+ deleted_names = []
29
+
30
+ grouped_files.each do |(library, variant), files|
31
+ # Guard on library-specific evidence of use, NOT on the merged keep-set:
32
+ # a flat keep_icons list is non-empty for every library, so keying the
33
+ # guard off keep.empty? would let it wipe an entirely-unreferenced
34
+ # library down to whatever the flat list happens to match.
35
+ unless library_used?(library)
36
+ warn "[Glyphs::IconPruner] refusing to prune #{library}/#{variant || '.'} — " \
37
+ "no references, fallback, or per-library keep_icons for #{library}"
38
+ next
39
+ end
40
+
41
+ keep = keep_names_for(library, variant)
42
+ stat = prune_group(library, variant, files, keep, deleted_names)
43
+ stats << stat
44
+ end
45
+
46
+ PruneReport.new(stats:, deleted_names:, dry_run: @dry_run)
47
+ end
48
+
49
+ private
50
+
51
+ # { [library, variant] => [absolute svg paths] }, excluding the animated lib.
52
+ def grouped_files
53
+ svg_files.group_by { |path| library_and_variant(path) }.reject { |(library, _), _| library == ANIMATED }
54
+ end
55
+
56
+ def svg_files
57
+ Dir.glob([File.join(@icons_root, "*", "*", "*.svg"), File.join(@icons_root, "*", "*.svg")]).uniq
58
+ end
59
+
60
+ # Derives [library_sym, variant_or_nil] from an svg path under icons_root.
61
+ # Two dirs deep => library/variant/name.svg; one dir deep => library/name.svg.
62
+ def library_and_variant(path)
63
+ relative = path.delete_prefix("#{@icons_root}/")
64
+ parts = relative.split("/")
65
+ if parts.size >= 3
66
+ [parts[0].to_sym, parts[1]]
67
+ else
68
+ [parts[0].to_sym, nil]
69
+ end
70
+ end
71
+
72
+ def prune_group(library, variant, files, keep, deleted_names)
73
+ kept = 0
74
+ deleted = 0
75
+ bytes_freed = 0
76
+
77
+ files.each do |path|
78
+ name = File.basename(path, ".svg")
79
+ if keep_file?(name, keep)
80
+ kept += 1
81
+ next
82
+ end
83
+
84
+ deleted += 1
85
+ bytes_freed += File.size(path)
86
+ deleted_names << name
87
+ File.delete(path) unless @dry_run
88
+ end
89
+
90
+ PruneReport::LibraryStat.new(library:, variant:, kept:, deleted:, bytes_freed:)
91
+ end
92
+
93
+ def keep_file?(name, keep)
94
+ keep.any? { |pattern| pattern == name || File.fnmatch?(pattern, name) }
95
+ end
96
+
97
+ # The set of names/globs to keep for a (library, variant): references for
98
+ # this exact library+variant, the library's keep_icons entries, and the
99
+ # library's fallback icon.
100
+ def keep_names_for(library, variant)
101
+ names = Set.new
102
+ @references.each do |reference|
103
+ names << reference.name if reference.library == library && reference.variant == variant
104
+ end
105
+ names.merge(keep_icons_for(library))
106
+ fallback = @fallback_icons[library]
107
+ names << fallback if fallback
108
+ names
109
+ end
110
+
111
+ def keep_icons_for(library)
112
+ case @keep_icons
113
+ when Hash then Array(@keep_icons[library] || @keep_icons[library.to_s])
114
+ else Array(@keep_icons)
115
+ end
116
+ end
117
+
118
+ # Whether a library has library-specific evidence it's in use: any scanned
119
+ # reference, a configured fallback, or a per-library keep_icons entry. A flat
120
+ # keep_icons list is deliberately NOT evidence — it applies to every library,
121
+ # so counting it would defeat the wipe guard for unreferenced libraries.
122
+ def library_used?(library)
123
+ return true if @references.any? { |reference| reference.library == library }
124
+ return true if @fallback_icons[library]
125
+
126
+ # A per-library keep_icons entry counts only if it actually lists icons —
127
+ # an empty array (`{ tabler: [] }`) is not evidence of use.
128
+ @keep_icons.is_a?(Hash) && keep_icons_for(library).any?
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # A single (library, variant, name) icon reference discovered in source, with
5
+ # the shared library/variant resolution tables the scanner and the RuboCop
6
+ # cops both rely on.
7
+ #
8
+ # `library` is an icons-gem library symbol (`:lucide`), `variant` is a string
9
+ # or nil (variant-less libraries), and `name` is dasherized (`"circle-check"`).
10
+ #
11
+ # The library/variant tables are sourced from the `icons` gem wherever
12
+ # possible (`default_variant_for`) so they never drift from what actually
13
+ # resolves at render time.
14
+ class IconReference < Data.define(:library, :variant, :name) # rubocop:disable Style/DataInheritance
15
+ # Subclassed (not the block form) so the constants below are namespaced under
16
+ # IconReference — constants defined inside a `Data.define do…end` block leak
17
+ # to the enclosing scope instead of nesting under the class.
18
+ #
19
+ # Maps every Glyphs library component to its icons-gem library symbol. This
20
+ # is the canonical map; `RuboCop::Cop::Glyphs::LibraryCallHelpers` reads it
21
+ # so the cops and the pruner agree on the full set of libraries.
22
+ LIBRARY_TO_COMPONENT = {
23
+ lucide: "LucideIcon",
24
+ phosphor: "PhosphorIcon",
25
+ heroicons: "HeroIcon",
26
+ tabler: "TablerIcon",
27
+ feather: "FeatherIcon",
28
+ boxicons: "BoxIcon",
29
+ flags: "FlagIcon",
30
+ hugeicons: "HugeIcon",
31
+ linear: "LinearIcon",
32
+ radix: "RadixIcon",
33
+ sidekickicons: "SidekickIcon",
34
+ weather: "WeatherIcon",
35
+ animated: "AnimatedIcon"
36
+ }.freeze
37
+
38
+ # Legacy icon helpers (`_lucide(:house)`) the scanner also recognizes.
39
+ LEGACY_HELPERS = {
40
+ "_lucide" => :lucide,
41
+ "_phosphor" => :phosphor,
42
+ "_hero" => :heroicons,
43
+ "_heroicon" => :heroicons,
44
+ "_tabler" => :tabler
45
+ }.freeze
46
+
47
+ # Raw `iconify lucide--house` class strings.
48
+ ICONIFY_PATTERN = /\biconify\s+(lucide|phosphor|heroicons)--([a-z0-9-]+)/
49
+
50
+ class << self
51
+ # Component name (`"LucideIcon"`) => library symbol (`:lucide`).
52
+ def component_to_library
53
+ @component_to_library ||= LIBRARY_TO_COMPONENT.to_h { |library, component| [component, library] }.freeze
54
+ end
55
+
56
+ def legacy_helpers
57
+ LEGACY_HELPERS
58
+ end
59
+
60
+ # Resolves a called method name (component or legacy helper) to a library
61
+ # symbol, or nil when the method isn't an icon call.
62
+ def library_for(method_name)
63
+ name = method_name.to_s
64
+ component_to_library[name] || LEGACY_HELPERS[name]
65
+ end
66
+
67
+ # The library's default variant, as configured in the icons gem. Returns a
68
+ # string (`"outline"`), or nil for variant-less libraries.
69
+ def default_variant_for(library)
70
+ options = Icons.config.libraries[library.to_sym]
71
+ normalize_variant(options&.default_variant)
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
76
+ # Mirrors the icons gem's variant→path handling so a scanned reference
77
+ # points at the same file that renders: an empty string (a documented
78
+ # rails_icons override) and `"."` (the variant-less convention) both mean
79
+ # "no variant subdirectory" and normalize to nil. See the icons gem's
80
+ # Icons::Icon::Configurable#set_variant and Icons::Icon::FilePath#parts.
81
+ def normalize_variant(variant)
82
+ return if variant.nil?
83
+
84
+ string = variant.to_s
85
+ return if string.empty? || string == "."
86
+
87
+ string
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # The result of an IconPruner run: per-(library, variant) counts, a sample of
5
+ # deleted names, and a human-readable summary. Value object, no side effects.
6
+ class PruneReport
7
+ LibraryStat = Data.define(:library, :variant, :kept, :deleted, :bytes_freed)
8
+
9
+ SAMPLE_LIMIT = 20
10
+
11
+ attr_reader :stats, :deleted_names, :dry_run
12
+
13
+ def initialize(stats:, deleted_names:, dry_run:)
14
+ @stats = stats
15
+ @deleted_names = deleted_names
16
+ @dry_run = dry_run
17
+ end
18
+
19
+ def deleted_count = stats.sum(&:deleted)
20
+ def kept_count = stats.sum(&:kept)
21
+ def bytes_freed = stats.sum(&:bytes_freed)
22
+
23
+ def to_s
24
+ lines = [headline, *stat_lines]
25
+ lines << sample_line if deleted_names.any?
26
+ lines << " Re-run with PRUNE=1 GLYPHS_PRUNE_ICONS=1 to delete." if dry_run
27
+ lines.join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ def headline
33
+ prefix = dry_run ? "[dry-run] " : ""
34
+ verb = dry_run ? "Would prune" : "Pruned"
35
+ "#{prefix}#{verb} #{deleted_count} icons, kept #{kept_count}, freed #{human_bytes(bytes_freed)}"
36
+ end
37
+
38
+ def stat_lines
39
+ stats.sort_by { |stat| [stat.library.to_s, stat.variant.to_s] }.map do |stat|
40
+ " #{stat.library}/#{stat.variant || '.'}: #{stat.deleted} deleted, #{stat.kept} kept"
41
+ end
42
+ end
43
+
44
+ def sample_line
45
+ shown = deleted_names.first(SAMPLE_LIMIT).join(", ")
46
+ extra = deleted_names.size - SAMPLE_LIMIT
47
+ extra.positive? ? " e.g. #{shown} … and #{extra} more" : " e.g. #{shown}"
48
+ end
49
+
50
+ def human_bytes(bytes)
51
+ units = %w[B KB MB GB]
52
+ size = bytes.to_f
53
+ unit = units.shift
54
+ while size >= 1024 && units.any?
55
+ size /= 1024
56
+ unit = units.shift
57
+ end
58
+ unit == "B" ? "#{bytes} B" : format("%.2f %s", size, unit)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # Wires configuration → SourceScanner → IconPruner and (when deleting) verifies
5
+ # every kept icon still resolves on disk. Thin orchestrator so the rake task is
6
+ # a one-liner and the whole flow is unit-testable.
7
+ #
8
+ # Glyphs::PruneRunner.call(dry_run: false) # scans Rails.root, prunes, verifies
9
+ class PruneRunner
10
+ # Raised when a kept icon (static ref, keep_icons, or fallback) is missing
11
+ # after a prune — turns a bad prune into a failed build, never a 500.
12
+ class VerificationError < StandardError; end
13
+
14
+ def self.call(**)
15
+ new(**).call
16
+ end
17
+
18
+ def initialize(root: default_root, icons_root: default_icons_root, dry_run: true, config: Glyphs.configuration)
19
+ @root = root
20
+ @icons_root = icons_root
21
+ @dry_run = dry_run
22
+ @config = config
23
+ end
24
+
25
+ def call
26
+ report = pruner.call
27
+ verify! unless @dry_run
28
+ report
29
+ end
30
+
31
+ # Asserts every kept icon resolves to a file on disk. Raises
32
+ # VerificationError listing the first few misses.
33
+ def verify!
34
+ missing = expected_files.reject { |path| File.exist?(path) }
35
+ return if missing.empty?
36
+
37
+ names = missing.first(10).map { |path| relative(path) }
38
+ raise VerificationError,
39
+ "#{missing.size} kept icon(s) missing after prune: #{names.join(', ')}"
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :config
45
+
46
+ def pruner
47
+ @pruner ||= IconPruner.new(
48
+ icons_root: @icons_root,
49
+ references:,
50
+ keep_icons: keep_icons,
51
+ fallback_icons: config.fallback_icons,
52
+ dry_run: @dry_run
53
+ )
54
+ end
55
+
56
+ def scanner
57
+ @scanner ||= SourceScanner.new(root: @root, **scanner_options)
58
+ end
59
+
60
+ def references
61
+ @references ||= scanner.call
62
+ end
63
+
64
+ # The pruner's keep-set: the configured keep_icons MERGED with the scanner's
65
+ # advisory per-library dynamic keeps (names harvested from dynamic call
66
+ # sites). Both are advisory — kept if present, never asserted by verify!.
67
+ #
68
+ # With no dynamic keeps, the configured value passes straight through (flat
69
+ # list or hash — the pruner accepts both). Otherwise everything folds into a
70
+ # per-library hash; a flat configured list applies to every kept library.
71
+ def keep_icons
72
+ dynamic = scanner.dynamic_keeps
73
+ return config.keep_icons if dynamic.empty?
74
+
75
+ merged = Hash.new { |hash, key| hash[key] = [] }
76
+ dynamic.each { |library, names| merged[library].concat(names.to_a) }
77
+ merge_configured_keeps(merged)
78
+ merged.transform_values(&:uniq)
79
+ end
80
+
81
+ # Folds the configured keep_icons into the per-library `merged` hash: a hash
82
+ # merges per library; a flat list applies to every library already present.
83
+ def merge_configured_keeps(merged)
84
+ case config.keep_icons
85
+ when Hash
86
+ config.keep_icons.each { |library, names| merged[library.to_sym].concat(Array(names)) }
87
+ else
88
+ flat = Array(config.keep_icons)
89
+ merged.each_key { |library| merged[library].concat(flat) }
90
+ end
91
+ end
92
+
93
+ def scanner_options
94
+ globs = config.prune_source_globs
95
+ globs ? { extra_globs: Array(globs) } : {}
96
+ end
97
+
98
+ # Files that MUST exist after a prune: every scanned reference and every
99
+ # configured fallback icon, restricted to libraries actually synced under
100
+ # icons_root (the pruner only touches those — a fallback for an un-synced
101
+ # library is irrelevant and must not fail the build). keep_icons globs are
102
+ # advisory (they may match nothing), so they're not asserted here.
103
+ def expected_files
104
+ fallback_refs = config.fallback_icons.map do |library, name|
105
+ IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
106
+ end
107
+
108
+ (references.to_a + fallback_refs)
109
+ .select { |reference| library_present?(reference.library) }
110
+ .map { |reference| file_for(reference) }
111
+ .uniq
112
+ end
113
+
114
+ def library_present?(library)
115
+ Dir.exist?(File.join(@icons_root, library.to_s))
116
+ end
117
+
118
+ def file_for(reference)
119
+ parts = [reference.library.to_s, reference.variant, "#{reference.name}.svg"].compact
120
+ File.join(@icons_root, *parts)
121
+ end
122
+
123
+ def relative(path)
124
+ path.delete_prefix("#{@icons_root}/")
125
+ end
126
+
127
+ def default_root
128
+ defined?(Rails) ? Rails.root.to_s : Dir.pwd
129
+ end
130
+
131
+ def default_icons_root
132
+ base = Icons.config.base_path
133
+ File.join(base.to_s, Icons.config.icons_path.to_s)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # Loaded only when Rails is present (see the guard in lib/glyphs.rb). Its sole
5
+ # job is to expose the icon-pruning rake task to consuming apps. Kept a
6
+ # Railtie, not an Engine: glyphs contributes no routes, views, or migrations.
7
+ class Railtie < ::Rails::Railtie
8
+ rake_tasks do
9
+ load File.expand_path("../tasks/glyphs.rake", __dir__)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Glyphs
6
+ # Scans an application's source for statically-known icon references and
7
+ # returns a Set of IconReference. Ruby/Phlex files are parsed with Prism (a
8
+ # real AST); templates (.erb/.haml/.slim) are text-scanned for icon calls and
9
+ # `iconify` class strings.
10
+ #
11
+ # Dynamic names/variants/libraries are silently skipped — they can't be known
12
+ # statically and are covered by the pruner's keep-list. A file that fails to
13
+ # parse warns and is skipped rather than aborting the scan.
14
+ class SourceScanner
15
+ DEFAULT_RUBY_GLOBS = ["app/**/*.rb", "lib/**/*.rb"].freeze
16
+ DEFAULT_TEMPLATE_GLOBS = ["app/**/*.erb", "app/**/*.haml", "app/**/*.slim"].freeze
17
+
18
+ # Template (.erb/.haml/.slim) text scanning. Prism can't parse these, so we
19
+ # match icon calls textually: the method, the literal first-argument name,
20
+ # and the argument tail (to pull out `variant:` and, for the generic
21
+ # `icon`/`Icon` helper, `library:`/`from:`). `Icon`/`icon` are included here
22
+ # (they're handled by the AST visitor for .rb files) so the generic
23
+ # rails_icons view helper is caught in templates too.
24
+ TEMPLATE_CALL_NAMES = (
25
+ IconReference::LIBRARY_TO_COMPONENT.values +
26
+ IconReference::LEGACY_HELPERS.keys +
27
+ %w[Icon icon]
28
+ ).freeze
29
+ # The tail runs to the end of the line or the ERB/Slim tag close (`%>`,
30
+ # `-%>`) — NOT to the first `)`, which would truncate `variant:`/`from:` sitting
31
+ # after a nested call like `class: cn("a")`. Over-capturing the tail only ever
32
+ # ADDS a keep (never drops a reference), so it errs safe.
33
+ TEMPLATE_CALL_PATTERN = /
34
+ \b(#{Regexp.union(TEMPLATE_CALL_NAMES)})
35
+ \(?\s*[:"']([a-z0-9_-]+)['"]? # first arg is a literal symbol or string
36
+ ([^\n]*?) # argument tail (rest of the logical line)
37
+ (?=-?%>|\n|\z)
38
+ /x
39
+ TEMPLATE_VARIANT_PATTERN = /\bvariant:\s*[:"']?([a-z0-9_.-]*)/
40
+ TEMPLATE_LIBRARY_PATTERN = /\b(?:library|from):\s*[:"']([a-z0-9_-]+)/
41
+ GENERIC_TEMPLATE_CALLS = %w[Icon icon].freeze
42
+
43
+ # `extra_globs` are additional locations to text-scan on top of the built-in
44
+ # Ruby and template defaults (never replacing them) — e.g. a config file that
45
+ # names icons. They're scanned like templates (regex, not AST).
46
+ def initialize(root:, ruby_globs: DEFAULT_RUBY_GLOBS, template_globs: DEFAULT_TEMPLATE_GLOBS, extra_globs: [])
47
+ @root = root
48
+ @ruby_globs = ruby_globs
49
+ @template_globs = template_globs
50
+ @extra_globs = Array(extra_globs)
51
+ end
52
+
53
+ # Confirmed references: icons named by a LITERAL in an icon call (or an
54
+ # iconify string). These are guaranteed-real — the pruner keeps them AND the
55
+ # runner asserts every one still resolves on disk after a prune.
56
+ def call
57
+ scan
58
+ @references
59
+ end
60
+
61
+ # Advisory per-library keeps harvested from DYNAMIC call sites (see
62
+ # DynamicHarvest). `{ library => Set[names] }`. These may include literals
63
+ # that aren't real icons (a CSS class, a method name), so — like the
64
+ # configured keep_icons — they are kept if present but NEVER asserted by
65
+ # verification. Feed them into the pruner's keep path, not the runner's
66
+ # expected-files set.
67
+ def dynamic_keeps
68
+ scan
69
+ @dynamic_keeps
70
+ end
71
+
72
+ private
73
+
74
+ def scan
75
+ return if @scanned
76
+
77
+ @references = Set.new
78
+ harvest = DynamicHarvest.new
79
+ ruby_files.each { |path| scan_ruby(path, @references, harvest) }
80
+ (template_files - ruby_files).each { |path| scan_template(path, @references) }
81
+ @dynamic_keeps = harvest.to_h
82
+ @scanned = true
83
+ end
84
+
85
+ attr_reader :root
86
+
87
+ def ruby_files
88
+ glob(@ruby_globs)
89
+ end
90
+
91
+ def template_files
92
+ glob(@template_globs + @extra_globs)
93
+ end
94
+
95
+ def glob(patterns)
96
+ patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.uniq
97
+ end
98
+
99
+ def scan_ruby(path, references, harvest)
100
+ result = Prism.parse_file(path)
101
+ visitor = CallVisitor.new(references)
102
+ visitor.visit(result.value)
103
+ harvest.absorb(visitor)
104
+ rescue StandardError => e
105
+ warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
106
+ end
107
+
108
+ def scan_template(path, references)
109
+ text = File.read(path)
110
+ scan_iconify(text, references)
111
+ text.scan(TEMPLATE_CALL_PATTERN) do |method_name, name, tail|
112
+ add_template_call(references, method_name, name, tail)
113
+ end
114
+ rescue StandardError => e
115
+ warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
116
+ end
117
+
118
+ # Records a template icon call, reading `variant:` (and, for the generic
119
+ # icon/Icon helper, `library:`/`from:`) out of the captured argument tail so
120
+ # a variant-bearing template call resolves to the same file it renders.
121
+ def add_template_call(references, method_name, name, tail)
122
+ library = template_library(method_name, tail)
123
+ return unless library
124
+
125
+ references << IconReference.new(
126
+ library:,
127
+ variant: template_variant(tail, library),
128
+ name: name.to_s.tr("_", "-")
129
+ )
130
+ end
131
+
132
+ # A present-but-empty `variant:` (`variant: ""` / `variant: :"."`) means
133
+ # no-variant — mirror the AST path: default ONLY when the keyword is absent.
134
+ def template_variant(tail, library)
135
+ match = tail.match(TEMPLATE_VARIANT_PATTERN)
136
+ return IconReference.default_variant_for(library) unless match
137
+
138
+ IconReference.normalize_variant(match[1])
139
+ end
140
+
141
+ def template_library(method_name, tail)
142
+ return IconReference.library_for(method_name) unless GENERIC_TEMPLATE_CALLS.include?(method_name)
143
+
144
+ match = tail.match(TEMPLATE_LIBRARY_PATTERN)
145
+ match && match[1].to_sym
146
+ end
147
+
148
+ def scan_iconify(text, references)
149
+ text.scan(IconReference::ICONIFY_PATTERN) do |library, name|
150
+ library = library.to_sym
151
+ references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
152
+ end
153
+ end
154
+
155
+ # Accumulates the evidence needed to resolve DYNAMIC icon calls — ones whose
156
+ # name the AST can't read (`LucideIcon(tile[:icon])`) — across every scanned
157
+ # Ruby file, then turns that evidence into IconReferences.
158
+ #
159
+ # Two sources of candidate names, both harvested only from literals so we
160
+ # never invent a reference:
161
+ #
162
+ # 1. file-scoped — a file that dynamically renders library L contributes
163
+ # every icon-name-shaped literal in that file as a candidate for L.
164
+ # Catches ternaries, case/when, and locals near the call.
165
+ # 2. declaration-based — literals in icon-declaration positions ANYWHERE
166
+ # (a hash pair keyed `/icon/i`, or a constant named `/ICON/`) are
167
+ # candidates for EVERY dynamically-rendered library, since a bare
168
+ # `ICON = :bell` in one file is often rendered from another.
169
+ #
170
+ # Names resolve at the library's default variant: a dynamic call names no
171
+ # variant in practice, and the pruner keeps per (library, variant).
172
+ class DynamicHarvest
173
+ def initialize
174
+ @dynamic_libraries = Set.new
175
+ @file_literals = Hash.new { |hash, key| hash[key] = Set.new }
176
+ @declaration_literals = Set.new
177
+ end
178
+
179
+ # Folds one file's visitor results into the running accumulator.
180
+ def absorb(visitor)
181
+ @declaration_literals.merge(visitor.declaration_literals)
182
+ return if visitor.dynamic_libraries.empty?
183
+
184
+ @dynamic_libraries.merge(visitor.dynamic_libraries)
185
+ visitor.dynamic_libraries.each do |library|
186
+ @file_literals[library].merge(visitor.file_literals)
187
+ end
188
+ end
189
+
190
+ # `{ library => Set[candidate names] }` for every dynamically-rendered
191
+ # library. Advisory: names may not be real icons, so callers keep them but
192
+ # must not assert they exist.
193
+ def to_h
194
+ @dynamic_libraries.to_h do |library|
195
+ [library, @file_literals[library] | @declaration_literals]
196
+ end
197
+ end
198
+ end
199
+
200
+ # Walks the Prism AST collecting icon references from call nodes.
201
+ class CallVisitor < Prism::Visitor
202
+ GENERIC_CALLS = %i[Icon icon].freeze
203
+
204
+ # A literal that could be an icon name: lowercase, digits, dashes/underscores.
205
+ # Excludes CSS classes (spaces/slashes), paths, and interpolated strings.
206
+ ICON_NAME_LITERAL = /\A[a-z][a-z0-9_-]*\z/
207
+
208
+ # Libraries this file renders with a dynamic (non-literal) first argument.
209
+ attr_reader :dynamic_libraries
210
+ # Icon-name-shaped literals anywhere in this file (file-scoped candidates).
211
+ attr_reader :file_literals
212
+ # Literals in icon-declaration positions (`icon:` pairs, `ICON` constants).
213
+ attr_reader :declaration_literals
214
+
215
+ def initialize(references)
216
+ @references = references
217
+ @dynamic_libraries = Set.new
218
+ @file_literals = Set.new
219
+ @declaration_literals = Set.new
220
+ super()
221
+ end
222
+
223
+ def visit_call_node(node)
224
+ record(node) if node.receiver.nil?
225
+ super
226
+ end
227
+
228
+ def visit_string_node(node)
229
+ node.unescaped.scan(IconReference::ICONIFY_PATTERN) do |library, name|
230
+ library = library.to_sym
231
+ references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
232
+ end
233
+ collect_literal(@file_literals, node)
234
+ super
235
+ end
236
+
237
+ def visit_symbol_node(node)
238
+ collect_literal(@file_literals, node)
239
+ super
240
+ end
241
+
242
+ # `ICON = :bell` / `CHANNEL_ICONS = { .. => :whatsapp_logo }` — a constant
243
+ # named like an icon is a declaration of icon name(s), wherever it lives.
244
+ def visit_constant_write_node(node)
245
+ collect_declaration(node.value) if node.name.to_s.match?(/ICON/i)
246
+ super
247
+ end
248
+
249
+ # `icon: :activity` / `menu_icon: "gear"` — a hash pair keyed like an icon
250
+ # declares an icon name, wherever it lives.
251
+ def visit_assoc_node(node)
252
+ key = node.key
253
+ collect_declaration(node.value) if key.is_a?(Prism::SymbolNode) && key.unescaped.match?(/icon/i)
254
+ super
255
+ end
256
+
257
+ private
258
+
259
+ attr_reader :references
260
+
261
+ def record(node)
262
+ method = node.name.to_s
263
+ if GENERIC_CALLS.include?(node.name)
264
+ record_generic(node)
265
+ elsif IconReference.library_for(method)
266
+ record_component(node, IconReference.library_for(method))
267
+ end
268
+ end
269
+
270
+ def record_component(node, library)
271
+ name = literal_string(first_argument(node))
272
+ # A dynamic first arg (variable, method call, hash lookup) can't be read
273
+ # off the call — flag the library so the harvest resolves its name from
274
+ # file-scoped and declaration literals instead.
275
+ unless name
276
+ @dynamic_libraries << library if first_argument(node)
277
+ return
278
+ end
279
+
280
+ variant = variant_for(node, library)
281
+ return if variant == :dynamic
282
+
283
+ references << IconReference.new(library:, variant:, name:)
284
+ end
285
+
286
+ def record_generic(node)
287
+ library = literal_symbol_or_string(keyword_argument(node, %i[library from]))
288
+ return unless library
289
+
290
+ name = literal_string(first_argument(node))
291
+ return unless name
292
+
293
+ variant = variant_for(node, library.to_sym)
294
+ return if variant == :dynamic
295
+
296
+ references << IconReference.new(library: library.to_sym, variant:, name:)
297
+ end
298
+
299
+ def variant_for(node, library)
300
+ value_node = keyword_argument(node, [:variant])
301
+ return IconReference.default_variant_for(library) unless value_node
302
+
303
+ value = literal_symbol_or_string(value_node)
304
+ return :dynamic unless value
305
+
306
+ IconReference.normalize_variant(value)
307
+ end
308
+
309
+ def first_argument(node)
310
+ node.arguments&.arguments&.first
311
+ end
312
+
313
+ # Returns the value node for the first matching keyword, or nil.
314
+ def keyword_argument(node, keys)
315
+ pair = keyword_pairs(node).find do |element|
316
+ element.key.is_a?(Prism::SymbolNode) && keys.include?(element.key.unescaped.to_sym)
317
+ end
318
+ pair&.value
319
+ end
320
+
321
+ def keyword_pairs(node)
322
+ args = node.arguments&.arguments || []
323
+ hash = args.find { |arg| arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode) }
324
+ hash ? hash.elements.grep(Prism::AssocNode) : []
325
+ end
326
+
327
+ def literal_string(value_node)
328
+ literal_symbol_or_string(value_node)&.tr("_", "-")
329
+ end
330
+
331
+ # Returns the literal String for a symbol/string node, or nil when the
332
+ # node is dynamic (a variable, method call, interpolation, ...).
333
+ def literal_symbol_or_string(value_node)
334
+ value_node.unescaped if value_node.is_a?(Prism::SymbolNode) || value_node.is_a?(Prism::StringNode)
335
+ end
336
+
337
+ # Adds a single symbol/string literal to `set` when it's shaped like an
338
+ # icon name (dasherized). Interpolated strings (StringNode with no static
339
+ # `unescaped`) and non-name-shaped literals are ignored.
340
+ def collect_literal(set, node)
341
+ raw = node.unescaped
342
+ return unless raw.is_a?(String) && raw.match?(ICON_NAME_LITERAL)
343
+
344
+ set << raw.tr("_", "-")
345
+ end
346
+
347
+ # Harvests icon names from a declaration value: a bare literal, an array of
348
+ # literals (`ICON = %i[a b]`), or a hash's values (`{ "sms" => :device }`).
349
+ def collect_declaration(value_node)
350
+ case value_node
351
+ when Prism::SymbolNode, Prism::StringNode
352
+ collect_literal(@declaration_literals, value_node)
353
+ when Prism::ArrayNode
354
+ value_node.elements.each { |element| collect_declaration(element) }
355
+ when Prism::HashNode
356
+ value_node.elements.grep(Prism::AssocNode).each { |assoc| collect_declaration(assoc.value) }
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Glyphs
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/glyphs.rb CHANGED
@@ -11,8 +11,15 @@ loader.ignore("#{__dir__}/glyphs/version.rb")
11
11
  # RuboCop process (see the bottom of this file).
12
12
  loader.ignore("#{__dir__}/glyphs/rubocop.rb")
13
13
  loader.ignore("#{__dir__}/rubocop")
14
+ # The Railtie is required conditionally below (only under Rails), and the rake
15
+ # tasks aren't Ruby constants — keep both out of Zeitwerk's managed set.
16
+ loader.ignore("#{__dir__}/glyphs/railtie.rb")
17
+ loader.ignore("#{__dir__}/tasks")
14
18
  loader.setup
15
19
 
20
+ # Expose the icon-pruning rake task to consuming Rails apps.
21
+ require_relative "glyphs/railtie" if defined?(Rails::Railtie)
22
+
16
23
  # Ensure the icons gem is configured even outside Rails. Inside Rails the
17
24
  # rails_icons engine calls `Icons.configure` with the app's settings; the
18
25
  # configuration is memoized, so this never clobbers app configuration.
@@ -9,21 +9,10 @@ module RuboCop
9
9
  module LibraryCallHelpers
10
10
  LIBRARY_KEYS = %i[library from].freeze
11
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
12
+ # Library name string => component name, keyed by the icons-gem library
13
+ # name (`"lucide"`). Derived from the canonical map in
14
+ # `Glyphs::IconReference` so the cops and the icon pruner never drift.
15
+ LIBRARY_TO_COMPONENT = ::Glyphs::IconReference::LIBRARY_TO_COMPONENT.transform_keys(&:to_s).freeze
27
16
 
28
17
  private
29
18
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :glyphs do
4
+ desc "Prune synced icons not referenced in source. Dry-run unless PRUNE=1 GLYPHS_PRUNE_ICONS=1."
5
+ task prune_icons: :environment do
6
+ commit = ENV["PRUNE"] == "1" && ENV["GLYPHS_PRUNE_ICONS"] == "1"
7
+
8
+ begin
9
+ report = Glyphs::PruneRunner.call(dry_run: !commit)
10
+ rescue Glyphs::PruneRunner::VerificationError => e
11
+ warn "[glyphs:prune_icons] #{e.message}"
12
+ abort "[glyphs:prune_icons] prune verification failed — aborting."
13
+ end
14
+
15
+ puts report
16
+ end
17
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glyphs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -73,12 +73,18 @@ files:
73
73
  - lib/glyphs/hero_icon.rb
74
74
  - lib/glyphs/huge_icon.rb
75
75
  - lib/glyphs/icon.rb
76
+ - lib/glyphs/icon_pruner.rb
77
+ - lib/glyphs/icon_reference.rb
76
78
  - lib/glyphs/linear_icon.rb
77
79
  - lib/glyphs/lucide_icon.rb
78
80
  - lib/glyphs/phosphor_icon.rb
81
+ - lib/glyphs/prune_report.rb
82
+ - lib/glyphs/prune_runner.rb
79
83
  - lib/glyphs/radix_icon.rb
84
+ - lib/glyphs/railtie.rb
80
85
  - lib/glyphs/rubocop.rb
81
86
  - lib/glyphs/sidekick_icon.rb
87
+ - lib/glyphs/source_scanner.rb
82
88
  - lib/glyphs/tabler_icon.rb
83
89
  - lib/glyphs/version.rb
84
90
  - lib/glyphs/weather_icon.rb
@@ -86,6 +92,7 @@ files:
86
92
  - lib/rubocop/cop/glyphs/legacy_icon_helper.rb
87
93
  - lib/rubocop/cop/glyphs/library_call_helpers.rb
88
94
  - lib/rubocop/cop/glyphs/prefer_library_component.rb
95
+ - lib/tasks/glyphs.rake
89
96
  homepage: https://github.com/mhenrixon/glyphs
90
97
  licenses:
91
98
  - MIT
@@ -102,7 +109,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
102
109
  requirements:
103
110
  - - ">="
104
111
  - !ruby/object:Gem::Version
105
- version: '3.2'
112
+ version: '3.4'
106
113
  required_rubygems_version: !ruby/object:Gem::Requirement
107
114
  requirements:
108
115
  - - ">="