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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +76 -0
- data/lib/glyphs/configuration.rb +10 -1
- data/lib/glyphs/icon_pruner.rb +131 -0
- data/lib/glyphs/icon_reference.rb +91 -0
- data/lib/glyphs/prune_report.rb +61 -0
- data/lib/glyphs/prune_runner.rb +136 -0
- data/lib/glyphs/railtie.rb +12 -0
- data/lib/glyphs/source_scanner.rb +361 -0
- data/lib/glyphs/version.rb +1 -1
- data/lib/glyphs.rb +7 -0
- data/lib/rubocop/cop/glyphs/library_call_helpers.rb +4 -15
- data/lib/tasks/glyphs.rake +17 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 245c7101b5dad69bf73859eea7a5d7c9027f1573d4e1b4ccbcde2bed3e61266f
|
|
4
|
+
data.tar.gz: 585c169414705979b1c45bf02ad87de324c5e6a16fb6a8ddb0e631961820e533
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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):
|
data/lib/glyphs/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/glyphs/version.rb
CHANGED
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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.
|
|
112
|
+
version: '3.4'
|
|
106
113
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
114
|
requirements:
|
|
108
115
|
- - ">="
|