glyphs 0.2.1 → 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 +31 -4
- data/lib/glyphs/prune_runner.rb +35 -2
- data/lib/glyphs/source_scanner.rb +141 -7
- data/lib/glyphs/version.rb +1 -1
- metadata +1 -1
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
|
@@ -114,14 +114,41 @@ set for development.
|
|
|
114
114
|
It scans your source (`app/**/*.rb`, `lib/**/*.rb` with a real parser, plus
|
|
115
115
|
`.erb/.haml/.slim` text) for `LucideIcon(:house)`, legacy helpers, generic
|
|
116
116
|
`Icon(:x, library: :lucide)`, and `iconify lucide--house` class strings, then
|
|
117
|
-
keeps that set **plus** two safety nets: the `keep_icons` allowlist
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
120
148
|
|
|
121
149
|
```ruby
|
|
122
150
|
# config/initializers/glyphs.rb
|
|
123
151
|
Glyphs.configure do |config|
|
|
124
|
-
# Icons referenced dynamically (names from config, a DB, or a gem's chrome).
|
|
125
152
|
# Flat list or per-library hash; names or fnmatch globs.
|
|
126
153
|
config.keep_icons = %w[menu palette search circle-*]
|
|
127
154
|
# config.keep_icons = { lucide: %w[menu palette], phosphor: %w[lock] }
|
data/lib/glyphs/prune_runner.rb
CHANGED
|
@@ -47,14 +47,47 @@ module Glyphs
|
|
|
47
47
|
@pruner ||= IconPruner.new(
|
|
48
48
|
icons_root: @icons_root,
|
|
49
49
|
references:,
|
|
50
|
-
keep_icons:
|
|
50
|
+
keep_icons: keep_icons,
|
|
51
51
|
fallback_icons: config.fallback_icons,
|
|
52
52
|
dry_run: @dry_run
|
|
53
53
|
)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def scanner
|
|
57
|
+
@scanner ||= SourceScanner.new(root: @root, **scanner_options)
|
|
58
|
+
end
|
|
59
|
+
|
|
56
60
|
def references
|
|
57
|
-
@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
|
|
58
91
|
end
|
|
59
92
|
|
|
60
93
|
def scanner_options
|
|
@@ -50,15 +50,38 @@ module Glyphs
|
|
|
50
50
|
@extra_globs = Array(extra_globs)
|
|
51
51
|
end
|
|
52
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.
|
|
53
56
|
def call
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
58
70
|
end
|
|
59
71
|
|
|
60
72
|
private
|
|
61
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
|
+
|
|
62
85
|
attr_reader :root
|
|
63
86
|
|
|
64
87
|
def ruby_files
|
|
@@ -73,9 +96,11 @@ module Glyphs
|
|
|
73
96
|
patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.uniq
|
|
74
97
|
end
|
|
75
98
|
|
|
76
|
-
def scan_ruby(path, references)
|
|
99
|
+
def scan_ruby(path, references, harvest)
|
|
77
100
|
result = Prism.parse_file(path)
|
|
78
|
-
CallVisitor.new(references)
|
|
101
|
+
visitor = CallVisitor.new(references)
|
|
102
|
+
visitor.visit(result.value)
|
|
103
|
+
harvest.absorb(visitor)
|
|
79
104
|
rescue StandardError => e
|
|
80
105
|
warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
|
|
81
106
|
end
|
|
@@ -127,12 +152,71 @@ module Glyphs
|
|
|
127
152
|
end
|
|
128
153
|
end
|
|
129
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
|
+
|
|
130
200
|
# Walks the Prism AST collecting icon references from call nodes.
|
|
131
201
|
class CallVisitor < Prism::Visitor
|
|
132
202
|
GENERIC_CALLS = %i[Icon icon].freeze
|
|
133
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
|
+
|
|
134
215
|
def initialize(references)
|
|
135
216
|
@references = references
|
|
217
|
+
@dynamic_libraries = Set.new
|
|
218
|
+
@file_literals = Set.new
|
|
219
|
+
@declaration_literals = Set.new
|
|
136
220
|
super()
|
|
137
221
|
end
|
|
138
222
|
|
|
@@ -146,6 +230,27 @@ module Glyphs
|
|
|
146
230
|
library = library.to_sym
|
|
147
231
|
references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
|
|
148
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)
|
|
149
254
|
super
|
|
150
255
|
end
|
|
151
256
|
|
|
@@ -164,7 +269,13 @@ module Glyphs
|
|
|
164
269
|
|
|
165
270
|
def record_component(node, library)
|
|
166
271
|
name = literal_string(first_argument(node))
|
|
167
|
-
|
|
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
|
|
168
279
|
|
|
169
280
|
variant = variant_for(node, library)
|
|
170
281
|
return if variant == :dynamic
|
|
@@ -222,6 +333,29 @@ module Glyphs
|
|
|
222
333
|
def literal_symbol_or_string(value_node)
|
|
223
334
|
value_node.unescaped if value_node.is_a?(Prism::SymbolNode) || value_node.is_a?(Prism::StringNode)
|
|
224
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
|
|
225
359
|
end
|
|
226
360
|
end
|
|
227
361
|
end
|
data/lib/glyphs/version.rb
CHANGED