glyphs 0.2.0 → 0.2.1

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: dffbe50e2d6b992be18142abb575887e4b11ee8b26ad2afec2a3d94f0ed418cd
4
+ data.tar.gz: 59e4a5c55154ae1075f0c4cf0c7a3b2a1f767fc12416dd48b85d39b0bbed119a
5
5
  SHA512:
6
- metadata.gz: 3f9570e3186cf64e294a7ca0f4de342d5ef16ba7bd6ac86017737617566bef92caff25ea9f665a2d3044abe76990ec9ed81d020e0cd943bfc3983c4228f86360
7
- data.tar.gz: 49bd88b05cc3dfe3370348ade5a9498a26b6b7db12fb25fff63fb1ef0c8be94091cc71ef30728273a10a90be740d3fc0e867e751d5a505add4c1a1e0a0f741ce
6
+ metadata.gz: ef3a208721a23a5ab7272de80644228046b5f5bc42834d4f2f5f59f7da78bf141775cf6f8a192243abdcfdebdc444eab3bb87df33409fa429cc4571c76c4e0e5
7
+ data.tar.gz: cdc2a32800aa6c3a503dbccd0c8ee524e120fe1d418ba417bcf905f00e283fcc56c24be29dfec7f2dc4e0a6c57b268d2cb2577d9f5cc9b9b201483a6c0c93f2b
data/README.md CHANGED
@@ -103,6 +103,55 @@ 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 (for
118
+ dynamic/data-driven names a static scan can't see) and every configured
119
+ `fallback_icons` (a pruned fallback would 500 on the next missing icon).
120
+
121
+ ```ruby
122
+ # config/initializers/glyphs.rb
123
+ Glyphs.configure do |config|
124
+ # Icons referenced dynamically (names from config, a DB, or a gem's chrome).
125
+ # Flat list or per-library hash; names or fnmatch globs.
126
+ config.keep_icons = %w[menu palette search circle-*]
127
+ # config.keep_icons = { lucide: %w[menu palette], phosphor: %w[lock] }
128
+ end
129
+ ```
130
+
131
+ ```bash
132
+ # Preview (dry run — deletes nothing):
133
+ bin/rails glyphs:prune_icons
134
+
135
+ # Delete (both env vars required — a deliberate opt-in so it never fires
136
+ # accidentally on a developer's working tree):
137
+ PRUNE=1 GLYPHS_PRUNE_ICONS=1 bin/rails glyphs:prune_icons
138
+ ```
139
+
140
+ Run it in the image build, **after** `assets:precompile` and before the final
141
+ stage copies the app — so the deletions land in the image but never in a
142
+ developer's checkout:
143
+
144
+ ```dockerfile
145
+ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
146
+ RUN SECRET_KEY_BASE_DUMMY=1 PRUNE=1 GLYPHS_PRUNE_ICONS=1 ./bin/rails glyphs:prune_icons
147
+ ```
148
+
149
+ After deleting, the task **verifies** every kept icon still resolves and exits
150
+ non-zero if not — a bad prune (e.g. a missing allowlist entry) fails the build
151
+ instead of shipping broken icons. The bundled `animated` library and any
152
+ `custom_path` library are left untouched, and it refuses to empty a library
153
+ whose keep-set is empty.
154
+
106
155
  ## RuboCop cops
107
156
 
108
157
  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,103 @@
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: config.keep_icons,
51
+ fallback_icons: config.fallback_icons,
52
+ dry_run: @dry_run
53
+ )
54
+ end
55
+
56
+ def references
57
+ @references ||= SourceScanner.new(root: @root, **scanner_options).call
58
+ end
59
+
60
+ def scanner_options
61
+ globs = config.prune_source_globs
62
+ globs ? { extra_globs: Array(globs) } : {}
63
+ end
64
+
65
+ # Files that MUST exist after a prune: every scanned reference and every
66
+ # configured fallback icon, restricted to libraries actually synced under
67
+ # icons_root (the pruner only touches those — a fallback for an un-synced
68
+ # library is irrelevant and must not fail the build). keep_icons globs are
69
+ # advisory (they may match nothing), so they're not asserted here.
70
+ def expected_files
71
+ fallback_refs = config.fallback_icons.map do |library, name|
72
+ IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
73
+ end
74
+
75
+ (references.to_a + fallback_refs)
76
+ .select { |reference| library_present?(reference.library) }
77
+ .map { |reference| file_for(reference) }
78
+ .uniq
79
+ end
80
+
81
+ def library_present?(library)
82
+ Dir.exist?(File.join(@icons_root, library.to_s))
83
+ end
84
+
85
+ def file_for(reference)
86
+ parts = [reference.library.to_s, reference.variant, "#{reference.name}.svg"].compact
87
+ File.join(@icons_root, *parts)
88
+ end
89
+
90
+ def relative(path)
91
+ path.delete_prefix("#{@icons_root}/")
92
+ end
93
+
94
+ def default_root
95
+ defined?(Rails) ? Rails.root.to_s : Dir.pwd
96
+ end
97
+
98
+ def default_icons_root
99
+ base = Icons.config.base_path
100
+ File.join(base.to_s, Icons.config.icons_path.to_s)
101
+ end
102
+ end
103
+ 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,227 @@
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
+ def call
54
+ references = Set.new
55
+ ruby_files.each { |path| scan_ruby(path, references) }
56
+ (template_files - ruby_files).each { |path| scan_template(path, references) }
57
+ references
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :root
63
+
64
+ def ruby_files
65
+ glob(@ruby_globs)
66
+ end
67
+
68
+ def template_files
69
+ glob(@template_globs + @extra_globs)
70
+ end
71
+
72
+ def glob(patterns)
73
+ patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.uniq
74
+ end
75
+
76
+ def scan_ruby(path, references)
77
+ result = Prism.parse_file(path)
78
+ CallVisitor.new(references).visit(result.value)
79
+ rescue StandardError => e
80
+ warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
81
+ end
82
+
83
+ def scan_template(path, references)
84
+ text = File.read(path)
85
+ scan_iconify(text, references)
86
+ text.scan(TEMPLATE_CALL_PATTERN) do |method_name, name, tail|
87
+ add_template_call(references, method_name, name, tail)
88
+ end
89
+ rescue StandardError => e
90
+ warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
91
+ end
92
+
93
+ # Records a template icon call, reading `variant:` (and, for the generic
94
+ # icon/Icon helper, `library:`/`from:`) out of the captured argument tail so
95
+ # a variant-bearing template call resolves to the same file it renders.
96
+ def add_template_call(references, method_name, name, tail)
97
+ library = template_library(method_name, tail)
98
+ return unless library
99
+
100
+ references << IconReference.new(
101
+ library:,
102
+ variant: template_variant(tail, library),
103
+ name: name.to_s.tr("_", "-")
104
+ )
105
+ end
106
+
107
+ # A present-but-empty `variant:` (`variant: ""` / `variant: :"."`) means
108
+ # no-variant — mirror the AST path: default ONLY when the keyword is absent.
109
+ def template_variant(tail, library)
110
+ match = tail.match(TEMPLATE_VARIANT_PATTERN)
111
+ return IconReference.default_variant_for(library) unless match
112
+
113
+ IconReference.normalize_variant(match[1])
114
+ end
115
+
116
+ def template_library(method_name, tail)
117
+ return IconReference.library_for(method_name) unless GENERIC_TEMPLATE_CALLS.include?(method_name)
118
+
119
+ match = tail.match(TEMPLATE_LIBRARY_PATTERN)
120
+ match && match[1].to_sym
121
+ end
122
+
123
+ def scan_iconify(text, references)
124
+ text.scan(IconReference::ICONIFY_PATTERN) do |library, name|
125
+ library = library.to_sym
126
+ references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
127
+ end
128
+ end
129
+
130
+ # Walks the Prism AST collecting icon references from call nodes.
131
+ class CallVisitor < Prism::Visitor
132
+ GENERIC_CALLS = %i[Icon icon].freeze
133
+
134
+ def initialize(references)
135
+ @references = references
136
+ super()
137
+ end
138
+
139
+ def visit_call_node(node)
140
+ record(node) if node.receiver.nil?
141
+ super
142
+ end
143
+
144
+ def visit_string_node(node)
145
+ node.unescaped.scan(IconReference::ICONIFY_PATTERN) do |library, name|
146
+ library = library.to_sym
147
+ references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
148
+ end
149
+ super
150
+ end
151
+
152
+ private
153
+
154
+ attr_reader :references
155
+
156
+ def record(node)
157
+ method = node.name.to_s
158
+ if GENERIC_CALLS.include?(node.name)
159
+ record_generic(node)
160
+ elsif IconReference.library_for(method)
161
+ record_component(node, IconReference.library_for(method))
162
+ end
163
+ end
164
+
165
+ def record_component(node, library)
166
+ name = literal_string(first_argument(node))
167
+ return unless name
168
+
169
+ variant = variant_for(node, library)
170
+ return if variant == :dynamic
171
+
172
+ references << IconReference.new(library:, variant:, name:)
173
+ end
174
+
175
+ def record_generic(node)
176
+ library = literal_symbol_or_string(keyword_argument(node, %i[library from]))
177
+ return unless library
178
+
179
+ name = literal_string(first_argument(node))
180
+ return unless name
181
+
182
+ variant = variant_for(node, library.to_sym)
183
+ return if variant == :dynamic
184
+
185
+ references << IconReference.new(library: library.to_sym, variant:, name:)
186
+ end
187
+
188
+ def variant_for(node, library)
189
+ value_node = keyword_argument(node, [:variant])
190
+ return IconReference.default_variant_for(library) unless value_node
191
+
192
+ value = literal_symbol_or_string(value_node)
193
+ return :dynamic unless value
194
+
195
+ IconReference.normalize_variant(value)
196
+ end
197
+
198
+ def first_argument(node)
199
+ node.arguments&.arguments&.first
200
+ end
201
+
202
+ # Returns the value node for the first matching keyword, or nil.
203
+ def keyword_argument(node, keys)
204
+ pair = keyword_pairs(node).find do |element|
205
+ element.key.is_a?(Prism::SymbolNode) && keys.include?(element.key.unescaped.to_sym)
206
+ end
207
+ pair&.value
208
+ end
209
+
210
+ def keyword_pairs(node)
211
+ args = node.arguments&.arguments || []
212
+ hash = args.find { |arg| arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode) }
213
+ hash ? hash.elements.grep(Prism::AssocNode) : []
214
+ end
215
+
216
+ def literal_string(value_node)
217
+ literal_symbol_or_string(value_node)&.tr("_", "-")
218
+ end
219
+
220
+ # Returns the literal String for a symbol/string node, or nil when the
221
+ # node is dynamic (a variable, method call, interpolation, ...).
222
+ def literal_symbol_or_string(value_node)
223
+ value_node.unescaped if value_node.is_a?(Prism::SymbolNode) || value_node.is_a?(Prism::StringNode)
224
+ end
225
+ end
226
+ end
227
+ 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.1"
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.1
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
  - - ">="