glyphs 0.3.0 → 0.4.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: eb4ffc81dcfd96faf6a0f23e3f57780340db27348666e520c7bbc747af04065e
4
- data.tar.gz: 48e331ff55405e3c054fd0464a50a38ccf75df6d3e837e2a0edf3e3a18894de8
3
+ metadata.gz: 0e0ed91cb2af5c3414aa3db654c772ec9c3ad506f4f7d9998223b76144897d33
4
+ data.tar.gz: 37a81b162e603e83060c7adfd395cef55f1d20c4896d9bb4d4b0ec96e3e83908
5
5
  SHA512:
6
- metadata.gz: 382ddd08e2e3cd53ce5164bbb6104bfc46b2c1caf48ac5d39b24e08f3a7594cbd0fa95424793d746c616bc7e4e8621ded6c0af1c5bf4357fa309ded75c49c79a
7
- data.tar.gz: 1205d90f3d975f3a0880e3768d68c0daf12664c98efb01f715296fd1230dd24fb2af4981e74bd689f98ddf3b7b962c29dc7e3e4dd0125024c49944b312ba8995
6
+ metadata.gz: 82eb60a70f7fd553e38130cf57025834ca8daf9c02c54c315df9900c07346138eb1230d48783204509ab897791203434db38cf62ed1035337bf3ded8aee7b2ed
7
+ data.tar.gz: 188c29bbe5a8a8f6dbae785e39af4a6b49ee4c660a302bf8fd032525012c47ce7b6559e45c342f5b6544f06994162ab443b4e4fc8e51eb81b2c0bbc6ceb38152
data/CHANGELOG.md CHANGED
@@ -1,6 +1,39 @@
1
1
  # Changelog
2
2
 
3
- ## [0.3.0]
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+
7
+ - **A non-main Ractor renders icons it has not cached.** A miss there used to
8
+ raise `Ractor::IsolationError` (`@configuration from Icons`), because the
9
+ icons gem reads an unshareable module ivar and parses with Nokogiri, which
10
+ only the main Ractor can run. `Glyphs::MainRactor` now renders the miss on
11
+ the main Ractor through [ractor-dispatch](https://github.com/jhawthorn/ractor-dispatch)
12
+ (Ruby 4.0+, a dependency of Rails 8.2's Active Support) and that Ractor's
13
+ cache keeps the markup, byte for byte the main Ractor's. The missing-icon
14
+ policy runs on the main Ractor too. Without ractor-dispatch the miss raises
15
+ `Glyphs::MainRactor::Unavailable` naming the icon, and `Glyphs.preload_svg`
16
+ still seeds a Ractor's cache. glyphs does not depend on ractor-dispatch: add
17
+ it outside Rails 8.2. Refs zoolutions/phlex-reactive#363
18
+ - **`config.raise_on_prunable` fails a render the prune would delete.** Off by
19
+ default. With it on, typically in the test environment, rendering such an icon
20
+ raises `Glyphs::PrunableIconError`, naming the library, variant and name, so a
21
+ test fails instead of production. The guard asks the pruner, so under one
22
+ configuration the two agree. The first uncached render scans the source, once
23
+ per process; with the setting off nothing is scanned. Enabling it may fail specs, and each such
24
+ failure is an icon that would have gone missing in production. Refs #35
25
+
26
+ ### Fixed
27
+
28
+ - **`glyphs:prune_icons` no longer deletes icons forwarded through a wrapper
29
+ component.** A file that passes a non-literal under a key like an icon
30
+ (`Badge(icon:)`, `Badge(icon: row.first)`) now keeps its icon-name-shaped
31
+ literals for every dynamically-rendered library. They were dropped when the
32
+ file itself had no dynamic icon call, so the icon raised
33
+ `Icons::IconNotFound` in production only. Pruning can only keep a little more
34
+ than before. Refs #35
35
+
36
+ ## [0.3.0] - 2026-10-15
4
37
 
5
38
  ### Changed
6
39
 
@@ -136,6 +169,23 @@
136
169
  rather than the primary mechanism. Only literals are harvested, so the scanner
137
170
  never invents a reference.
138
171
 
172
+ ## [0.2.1] - 2026-07-11
173
+
174
+ ### Added
175
+
176
+ - **`glyphs:prune_icons` prunes unused icons for smaller Docker images.**
177
+ `Glyphs::SourceScanner` finds the icons an app references and the task deletes
178
+ every other synced SVG in the Docker build, keeping `config.keep_icons` (new,
179
+ for dynamic or data-driven names) and every `fallback_icons` entry, then
180
+ verifies each kept icon still resolves. Dry-run by default; it deletes only
181
+ with `PRUNE=1 GLYPHS_PRUNE_ICONS=1`. `config.prune_source_globs` adds paths to
182
+ scan. Refs #2
183
+
184
+ ### Changed
185
+
186
+ - **Breaking:** `required_ruby_version` is now `>= 3.4` (CI runs 3.4 and 4.0).
187
+ Refs #2
188
+
139
189
  ## [0.2.0] - 2026-07-07
140
190
 
141
191
  ### Changed
data/README.md CHANGED
@@ -130,11 +130,17 @@ readable on the main Ractor only, which is all an app without Ractors needs; ano
130
130
  object with a `#call` method that `Ractor.shareable?` accepts (frozen, with nothing unshareable inside) or
131
131
  `Ractor.shareable_proc` (Ruby 4.0+).
132
132
 
133
- The icons gem cannot render inside a non-main Ractor today (its configuration is unshareable module
134
- state, and Nokogiri is Ractor-unsafe), so each Ractor's cache starts empty and a miss there raises.
135
- Render on the main Ractor and hand the markup over with `Glyphs.preload_svg`; cache hits then work in that
136
- Ractor for calls without `data:` whose attributes the cache can apply (a `data:` call, or one with an attribute
137
- that renders uncached, still needs the icons gem):
133
+ The icons gem cannot render inside a non-main Ractor (its configuration is unshareable module state, and
134
+ Nokogiri is not marked Ractor-safe), so a miss in one renders on the main Ractor instead, through
135
+ [ractor-dispatch](https://github.com/jhawthorn/ractor-dispatch) (Ruby 4.0+). Rails 8.2 apps have it through
136
+ Active Support; elsewhere, add `gem "ractor-dispatch"`. The markup is the main Ractor's, byte for byte, and
137
+ the calling Ractor's cache keeps it, so each icon crosses once per cache entry; cache hits never leave the
138
+ Ractor. The missing-icon policy runs on the main Ractor too, `on_missing_icon` included. A call with an
139
+ attribute that renders uncached, or any call with `cache_svgs = false`, goes to the main Ractor every time.
140
+
141
+ Without ractor-dispatch, a miss in a non-main Ractor raises `Glyphs::MainRactor::Unavailable`, naming the
142
+ icon. You can still render on the main Ractor and hand the markup over with `Glyphs.preload_svg`; cache hits
143
+ then work in that Ractor for calls without `data:` whose attributes the cache can apply:
138
144
 
139
145
  ```ruby
140
146
  svg = Glyphs.svg_for(name: "house", library: :lucide, variant: nil, attributes: {}) # main Ractor
@@ -174,11 +180,48 @@ The scanner resolves them from source, so you rarely need `keep_icons` at all:
174
180
  constant named like one (`ICON = :bell`, `STATUS_ICONS = { .. => :warning }`,
175
181
  including trailing `.freeze`) — are kept for every dynamically-rendered
176
182
  library, so a name declared in one file and rendered from another survives.
183
+ - **Forwarded** — a file that passes a non-literal under a key like an icon
184
+ (`Badge(icon:)`, `Badge(icon: row.first)`) hands a name on to a wrapper
185
+ component, so every icon-name-shaped literal in that file is kept for every
186
+ dynamically-rendered library. `ROWS = [[:bell, 1]]` survives when the row
187
+ reaches `Badge(icon:)`, and `Badge`'s `LucideIcon(@icon)` lives in another file.
177
188
 
178
189
  Only literals are harvested, so the scanner never invents a reference; the
179
190
  worst case is keeping a coincidentally icon-named string, which the post-prune
180
191
  verification tolerates.
181
192
 
193
+ A static scan can't follow every value. A literal passed positionally to a
194
+ wrapper (`m.row("Alerts", :anchor)`) is still pruned, and so is a forwarded
195
+ keyword in a template, which is text-scanned. Nothing fails until production:
196
+ development and test keep every SVG, and the post-prune verification doesn't
197
+ assert harvested names. `raise_on_prunable` closes that gap.
198
+
199
+ ### `raise_on_prunable` — fail the test that renders a prunable icon
200
+
201
+ ```ruby
202
+ # config/environments/test.rb, or an initializer guarded by Rails.env.test?
203
+ Glyphs.configure do |config|
204
+ config.raise_on_prunable = true
205
+ end
206
+ ```
207
+
208
+ With it on, rendering an icon the prune would delete raises
209
+ `Glyphs::PrunableIconError`, naming the library, variant and name. Every page a
210
+ test renders becomes a check, and each failure is a production bug: name the icon
211
+ in an icon position (`icon: :anchor`, an `ICON` constant) or list it in
212
+ `keep_icons`.
213
+
214
+ The guard asks the pruner itself, so the two agree, including on `keep_icons`
215
+ globs and on the libraries a prune leaves alone. That holds for the configuration
216
+ the test runs with: a `keep_icons`, `fallback_icons` or `prune_source_globs` that
217
+ differs by `Rails.env` makes the guard answer for a different keep-set than your
218
+ image build prunes with. The first uncached render scans
219
+ your source, once per process (a parallel test worker scans for itself), and
220
+ cache hits never reach the check. A name that doesn't exist at all still meets
221
+ the missing-icon policy. The default is `false`, which scans nothing, and
222
+ source changed after the scan isn't seen until the process restarts, so keep this
223
+ in the test environment.
224
+
182
225
  ### `keep_icons` — the last-resort escape hatch
183
226
 
184
227
  For names a static scan genuinely can't see — read from a database, an ENV var,
@@ -24,8 +24,11 @@ module Glyphs
24
24
  # prune_source_globs: extra locations the pruner's scanner text-scans IN
25
25
  # ADDITION to the built-in Ruby/template defaults — e.g. a config file that
26
26
  # names icons. Never replaces the defaults (nil = defaults only).
27
+ # raise_on_prunable: boolean; when true, rendering an icon glyphs:prune_icons
28
+ # would delete raises Glyphs::PrunableIconError. For the test environment:
29
+ # the first uncached render scans the source, once per process.
27
30
  attr_accessor :raise_on_missing, :on_missing_icon, :fallback_icons, :cache_svgs,
28
- :keep_icons, :prune_source_globs
31
+ :keep_icons, :prune_source_globs, :raise_on_prunable
29
32
  attr_reader :svg_cache_size
30
33
 
31
34
  def initialize
@@ -36,6 +39,7 @@ module Glyphs
36
39
  @svg_cache_size = DEFAULT_SVG_CACHE_SIZE
37
40
  @keep_icons = []
38
41
  @prune_source_globs = nil
42
+ @raise_on_prunable = false
39
43
  end
40
44
 
41
45
  def svg_cache_size=(size)
@@ -38,14 +38,23 @@ module Glyphs
38
38
  next
39
39
  end
40
40
 
41
- keep = keep_names_for(library, variant)
42
- stat = prune_group(library, variant, files, keep, deleted_names)
43
- stats << stat
41
+ stats << prune_group(library, variant, files, unkept_files(library, variant, files), deleted_names)
44
42
  end
45
43
 
46
44
  PruneReport.new(stats:, deleted_names:, dry_run: @dry_run)
47
45
  end
48
46
 
47
+ # The Set of IconReferences #call would delete: same files, same keep-sets.
48
+ # Deletes nothing and warns about nothing.
49
+ def prunable
50
+ groups = grouped_files.select { |(library, _), _| library_used?(library) }
51
+ groups.each_with_object(Set.new) do |((library, variant), files), references|
52
+ unkept_files(library, variant, files).each do |path|
53
+ references << IconReference.new(library:, variant:, name: File.basename(path, ".svg"))
54
+ end
55
+ end
56
+ end
57
+
49
58
  private
50
59
 
51
60
  # { [library, variant] => [absolute svg paths] }, excluding the animated lib.
@@ -69,25 +78,19 @@ module Glyphs
69
78
  end
70
79
  end
71
80
 
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
81
+ # The one decision of what goes, shared by #call and #prunable.
82
+ def unkept_files(library, variant, files)
83
+ keep = keep_names_for(library, variant)
84
+ files.reject { |path| keep_file?(File.basename(path, ".svg"), keep) }
85
+ end
83
86
 
84
- deleted += 1
85
- bytes_freed += File.size(path)
86
- deleted_names << name
87
- File.delete(path) unless @dry_run
88
- end
87
+ def prune_group(library, variant, files, unkept, deleted_names)
88
+ bytes_freed = unkept.sum { |path| File.size(path) }
89
+ deleted_names.concat(unkept.map { |path| File.basename(path, ".svg") })
90
+ unkept.each { |path| File.delete(path) } unless @dry_run
89
91
 
90
- PruneReport::LibraryStat.new(library:, variant:, kept:, deleted:, bytes_freed:)
92
+ PruneReport::LibraryStat.new(library:, variant:, kept: files.size - unkept.size, deleted: unkept.size,
93
+ bytes_freed:)
91
94
  end
92
95
 
93
96
  def keep_file?(name, keep)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # Renders an icon on the main Ractor for a non-main one. The icons gem reads
5
+ # Icons.configuration (an unshareable module ivar) and parses with Nokogiri
6
+ # (a C extension not marked Ractor-safe), so only the main Ractor can run it.
7
+ # ractor-dispatch (Ruby 4.0+, a dependency of Rails 8.2's Active Support)
8
+ # runs the render there and sends the markup back, where the calling Ractor's
9
+ # own cache keeps it: each icon crosses once per Ractor, not once per render.
10
+ module MainRactor
11
+ # Raised in a non-main Ractor that cannot reach the main one.
12
+ class Unavailable < StandardError; end
13
+
14
+ class << self
15
+ # Whether this Ractor runs the icons gem itself: the main Ractor, or any
16
+ # code on an engine without Ractors.
17
+ def current?
18
+ defined?(::Ractor) ? ::Ractor.main? : true
19
+ end
20
+
21
+ # Glyphs' render (missing-icon policy included) of these arguments, run
22
+ # on the main Ractor; an Icons::IconNotFound it raises is raised here.
23
+ def render(**arguments)
24
+ dispatcher = main_dispatcher(arguments)
25
+ shared = ::Ractor.make_shareable(arguments, copy: true) # a shareable block captures only shareable values
26
+ dispatcher.run { Glyphs.__send__(:render_svg, **shared) }
27
+ end
28
+
29
+ private
30
+
31
+ def main_dispatcher(arguments)
32
+ icon = "#{arguments[:library]}/#{arguments[:name]}"
33
+ unless defined?(::Ractor::Port)
34
+ raise Unavailable, "#{icon} isn't cached in this Ractor, and rendering it on the main Ractor " \
35
+ "needs ractor-dispatch, which needs Ruby 4.0+. Seed the cache with Glyphs.preload_svg."
36
+ end
37
+
38
+ require "ractor/dispatch"
39
+ ::Ractor::Dispatch.main
40
+ rescue LoadError
41
+ raise Unavailable, "#{icon} isn't cached in this Ractor, and rendering it on the main Ractor " \
42
+ "needs the ractor-dispatch gem (Rails 8.2 apps have it): add gem \"ractor-dispatch\"."
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # Raised on render, with Configuration#raise_on_prunable on, for an icon that
5
+ # exists now and that glyphs:prune_icons would delete from the image. Not an
6
+ # Icons::IconNotFound: the missing-icon policy must not turn it into a fallback.
7
+ class PrunableIconError < StandardError; end
8
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # Backs Configuration#raise_on_prunable: a render of an icon the prune would
5
+ # delete fails in the test suite instead of in production, where development's
6
+ # full SVG tree no longer hides it. Dynamic keeps are advisory, so
7
+ # PruneRunner#verify! cannot catch this; a render names a real icon, so here
8
+ # it is safe to fail.
9
+ #
10
+ # It asks the pruner, so under one configuration the two agree, and it asks
11
+ # once: the scan runs in the constructor and #check! only reads the result. It
12
+ # deletes nothing. Glyphs keeps one per process, replacing it when #current?
13
+ # says the settings behind that answer changed.
14
+ class PruneGuard
15
+ def initialize(config: Glyphs.configuration, runner: PruneRunner.new(config:))
16
+ @keep_settings = keep_settings(config)
17
+ @prunable = runner.prunable
18
+ end
19
+
20
+ def current?(config)
21
+ @keep_settings == keep_settings(config)
22
+ end
23
+
24
+ def check!(name:, library:, variant:)
25
+ library = library.to_sym
26
+ variant = variant.nil? ? IconReference.default_variant_for(library) : IconReference.normalize_variant(variant)
27
+ reference = IconReference.new(library:, variant:, name: name.to_s)
28
+ raise PrunableIconError, message(reference) if @prunable.include?(reference)
29
+ end
30
+
31
+ private
32
+
33
+ # A hash of the values, so a list changed in place reads as a change.
34
+ def keep_settings(config)
35
+ [config.keep_icons, config.fallback_icons, config.prune_source_globs].hash
36
+ end
37
+
38
+ def message(reference)
39
+ icon = [reference.library, reference.variant, reference.name].compact.join("/")
40
+ "glyphs:prune_icons would delete #{icon}, so this render would fail in production. " \
41
+ "Name it in an icon position (`icon: :#{reference.name.tr('-', '_')}`, an `ICON` constant) " \
42
+ "or list it in keep_icons."
43
+ end
44
+ end
45
+ end
@@ -28,6 +28,12 @@ module Glyphs
28
28
  report
29
29
  end
30
30
 
31
+ # The IconReferences a prune would delete, deleting nothing: the pruner's own
32
+ # answer over the same scan and keep-sets #call uses.
33
+ def prunable
34
+ pruner.prunable
35
+ end
36
+
31
37
  # Asserts every kept icon resolves to a file on disk. Raises
32
38
  # VerificationError listing the first few misses.
33
39
  def verify!
@@ -156,7 +156,7 @@ module Glyphs
156
156
  # name the AST can't read (`LucideIcon(tile[:icon])`) — across every scanned
157
157
  # Ruby file, then turns that evidence into IconReferences.
158
158
  #
159
- # Two sources of candidate names, both harvested only from literals so we
159
+ # Three sources of candidate names, all harvested only from literals so we
160
160
  # never invent a reference:
161
161
  #
162
162
  # 1. file-scoped — a file that dynamically renders library L contributes
@@ -166,6 +166,10 @@ module Glyphs
166
166
  # (a hash pair keyed `/icon/i`, or a constant named `/ICON/`) are
167
167
  # candidates for EVERY dynamically-rendered library, since a bare
168
168
  # `ICON = :bell` in one file is often rendered from another.
169
+ # 3. forwarded — a file passing a non-literal under a key like an icon
170
+ # (`Badge(icon:)`, `Badge(icon: row.first)`) hands a name on to a wrapper
171
+ # that renders it elsewhere, so ALL its literals are candidates for
172
+ # every dynamically-rendered library, as declarations are.
169
173
  #
170
174
  # Names resolve at the library's default variant: a dynamic call names no
171
175
  # variant in practice, and the pruner keeps per (library, variant).
@@ -179,6 +183,7 @@ module Glyphs
179
183
  # Folds one file's visitor results into the running accumulator.
180
184
  def absorb(visitor)
181
185
  @declaration_literals.merge(visitor.declaration_literals)
186
+ @declaration_literals.merge(visitor.file_literals) if visitor.forwards_icon
182
187
  return if visitor.dynamic_libraries.empty?
183
188
 
184
189
  @dynamic_libraries.merge(visitor.dynamic_libraries)
@@ -205,18 +210,28 @@ module Glyphs
205
210
  # Excludes CSS classes (spaces/slashes), paths, and interpolated strings.
206
211
  ICON_NAME_LITERAL = /\A[a-z][a-z0-9_-]*\z/
207
212
 
213
+ # Values of an icon-keyed pair that forward nothing: a declaration harvest
214
+ # reads the names, and `icon: nil` or `icon_size: 16` names no icon at all.
215
+ LITERAL_VALUES = [
216
+ Prism::SymbolNode, Prism::StringNode, Prism::NilNode, Prism::TrueNode, Prism::FalseNode,
217
+ Prism::IntegerNode, Prism::FloatNode
218
+ ].freeze
219
+
208
220
  # Libraries this file renders with a dynamic (non-literal) first argument.
209
221
  attr_reader :dynamic_libraries
210
222
  # Icon-name-shaped literals anywhere in this file (file-scoped candidates).
211
223
  attr_reader :file_literals
212
224
  # Literals in icon-declaration positions (`icon:` pairs, `ICON` constants).
213
225
  attr_reader :declaration_literals
226
+ # Whether this file passes a non-literal under a key like an icon.
227
+ attr_reader :forwards_icon
214
228
 
215
229
  def initialize(references)
216
230
  @references = references
217
231
  @dynamic_libraries = Set.new
218
232
  @file_literals = Set.new
219
233
  @declaration_literals = Set.new
234
+ @forwards_icon = false
220
235
  super()
221
236
  end
222
237
 
@@ -247,10 +262,14 @@ module Glyphs
247
262
  end
248
263
 
249
264
  # `icon: :activity` / `menu_icon: "gear"` — a hash pair keyed like an icon
250
- # declares an icon name, wherever it lives.
265
+ # declares an icon name, wherever it lives. A non-literal value there
266
+ # (`icon:`, `icon: row.first`) forwards a name instead.
251
267
  def visit_assoc_node(node)
252
268
  key = node.key
253
- collect_declaration(node.value) if key.is_a?(Prism::SymbolNode) && key.unescaped.match?(/icon/i)
269
+ if key.is_a?(Prism::SymbolNode) && key.unescaped.match?(/icon/i)
270
+ collect_declaration(node.value)
271
+ @forwards_icon ||= forwarded_value?(node.value)
272
+ end
254
273
  super
255
274
  end
256
275
 
@@ -258,6 +277,20 @@ module Glyphs
258
277
 
259
278
  attr_reader :references
260
279
 
280
+ # A container forwards when anything inside it does: `icon: [:a, pick]`.
281
+ def forwarded_value?(value_node)
282
+ value_node = unwrap_declaration_wrappers(value_node)
283
+ case value_node
284
+ when Prism::ArrayNode then value_node.elements.any? { |element| forwarded_value?(element) }
285
+ when Prism::HashNode then value_node.elements.any? { |element| forwarded_pair?(element) }
286
+ else LITERAL_VALUES.none? { |literal| value_node.is_a?(literal) }
287
+ end
288
+ end
289
+
290
+ def forwarded_pair?(element)
291
+ !element.is_a?(Prism::AssocNode) || forwarded_value?(element.value)
292
+ end
293
+
261
294
  def record(node)
262
295
  method = node.name.to_s
263
296
  if GENERIC_CALLS.include?(node.name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Glyphs
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/glyphs.rb CHANGED
@@ -38,6 +38,8 @@ module Glyphs
38
38
  # readers never take it, so svg_for runs in any Ractor once finalized.
39
39
  @lock = Monitor.new
40
40
  @configuration = Configuration.new
41
+ @prune_guard = nil
42
+ @prune_guard_lock = Mutex.new
41
43
 
42
44
  class << self
43
45
  # The current configuration: a plain read, shareable after finalize!.
@@ -49,6 +51,7 @@ module Glyphs
49
51
  end
50
52
 
51
53
  def reset_configuration!
54
+ @prune_guard_lock.synchronize { @prune_guard = nil }
52
55
  @lock.synchronize { @configuration = Configuration.new }
53
56
  end
54
57
 
@@ -120,12 +123,36 @@ module Glyphs
120
123
 
121
124
  private
122
125
 
126
+ # A non-main Ractor cannot run the icons gem, so it renders on the main
127
+ # Ractor (see MainRactor); the missing-icon policy runs there too.
123
128
  def render_svg(name:, library:, variant:, attributes:)
129
+ return MainRactor.render(name:, library:, variant:, attributes:) unless MainRactor.current?
130
+
131
+ resolve_svg(name:, library:, variant:, attributes:)
132
+ end
133
+
134
+ # Only a miss gets here, and only on the main Ractor. A name with no file is
135
+ # never prunable, so it passes the guard and meets the missing-icon policy.
136
+ def resolve_svg(name:, library:, variant:, attributes:)
137
+ prune_guard&.check!(name:, library:, variant:)
124
138
  build_svg(name:, library:, variant:, attributes:)
125
139
  rescue Icons::IconNotFound => e
126
140
  handle_missing_icon(e, name:, library:, variant:, attributes:)
127
141
  end
128
142
 
143
+ # nil while raise_on_prunable is off, so that path reads nothing. A racing
144
+ # thread waits for the first scan rather than repeating it. Not @lock: this
145
+ # runs under the SvgCache mutex, and a configure block holding @lock may render.
146
+ def prune_guard
147
+ config = configuration
148
+ return unless config.raise_on_prunable
149
+
150
+ @prune_guard_lock.synchronize do
151
+ @prune_guard = PruneGuard.new(config:) unless @prune_guard&.current?(config)
152
+ @prune_guard
153
+ end
154
+ end
155
+
129
156
  def build_svg(name:, library:, variant:, attributes:)
130
157
  Icons::Icon.new(name:, library:, variant:, arguments: attributes).svg
131
158
  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.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -77,7 +77,10 @@ files:
77
77
  - lib/glyphs/icon_reference.rb
78
78
  - lib/glyphs/linear_icon.rb
79
79
  - lib/glyphs/lucide_icon.rb
80
+ - lib/glyphs/main_ractor.rb
80
81
  - lib/glyphs/phosphor_icon.rb
82
+ - lib/glyphs/prunable_icon_error.rb
83
+ - lib/glyphs/prune_guard.rb
81
84
  - lib/glyphs/prune_report.rb
82
85
  - lib/glyphs/prune_runner.rb
83
86
  - lib/glyphs/radix_icon.rb