glyphs 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83fd5e39450d1ac698d0add8d4344ecbd9001783695ac15f48f7276aba89a05f
4
- data.tar.gz: a1c570feaf9d4e623958a626d1a5ffe42fc55d0e4347cd659539e00335a8af3d
3
+ metadata.gz: 798fe439ca9ea93ff3bfb84f23de90f87abd6ffee953cd85de5543066b52a4f0
4
+ data.tar.gz: 58829c2ec4bf5ed6aaa24da8b40a376b7aae48525277be499eae957754a99c11
5
5
  SHA512:
6
- metadata.gz: b077d3f979ffcd2a41244c48fa6bba440400f44fb48d6033d35208661bfbfc434b65b22a44ee4548771bcac6ac87a97622e14dd6bb4303768066ae61a272ddb4
7
- data.tar.gz: a0b9a383c8197cc0079ecea51e3c9879fcb884b12e852aecf6f8a21dd8b0a06e9624bfb753f6a2faf02eec9043c90f4570e2ff40717ed2a648c9b93b52888b62
6
+ metadata.gz: 49acc1efae75b8d2eb0fac2079ef20c2d5fb211a519c598d61f306453c3dbc000f4ee2fd4bb75090b10a63f009e8bbbddde0e4a3277bc340b58e5eead644eec2
7
+ data.tar.gz: 7b3adbeb6816fbeb13eea1584a33ef1cbcb241339c7caedab14b92b44a185050ebd8240ae0ed99ab5715cc8ccaeb471968f491354e009112eee76ccbd23831a3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@
4
4
 
5
5
  ### Added
6
6
 
7
+ - **`config.merge_default_data` keeps a library's default data next to a
8
+ call's own.** Off by default. The icons gem replaces a library's configured
9
+ `default.data` with a call's `data:` hash, so a default marker such as
10
+ `data-icon` disappears from exactly the icons that carry data of their own.
11
+ With the setting on, the call's `data:` hash is merged over the defaults (the
12
+ call's keys win, its hash is not mutated; `data: nil` keeps the defaults, uncached).
13
+ Cached and uncached renders are byte-identical, the cache key is unchanged,
14
+ and a library with no default data renders as before. With it off, output is
15
+ unchanged. Refs #40
7
16
  - **A non-main Ractor renders icons it has not cached.** A miss there used to
8
17
  raise `Ractor::IsolationError` (`@configuration from Icons`), because the
9
18
  icons gem reads an unshareable module ivar and parses with Nokogiri, which
@@ -15,6 +24,23 @@
15
24
  `Glyphs::MainRactor::Unavailable` naming the icon, and `Glyphs.preload_svg`
16
25
  still seeds a Ractor's cache. glyphs does not depend on ractor-dispatch: add
17
26
  it outside Rails 8.2. Refs zoolutions/phlex-reactive#363
27
+ - **`config.raise_on_prunable` fails a render the prune would delete.** Off by
28
+ default. With it on, typically in the test environment, rendering such an icon
29
+ raises `Glyphs::PrunableIconError`, naming the library, variant and name, so a
30
+ test fails instead of production. The guard asks the pruner, so under one
31
+ configuration the two agree. The first uncached render scans the source, once
32
+ per process; with the setting off nothing is scanned. Enabling it may fail specs, and each such
33
+ failure is an icon that would have gone missing in production. Refs #35
34
+
35
+ ### Fixed
36
+
37
+ - **`glyphs:prune_icons` no longer deletes icons forwarded through a wrapper
38
+ component.** A file that passes a non-literal under a key like an icon
39
+ (`Badge(icon:)`, `Badge(icon: row.first)`) now keeps its icon-name-shaped
40
+ literals for every dynamically-rendered library. They were dropped when the
41
+ file itself had no dynamic icon call, so the icon raised
42
+ `Icons::IconNotFound` in production only. Pruning can only keep a little more
43
+ than before. Refs #35
18
44
 
19
45
  ## [0.3.0] - 2026-10-15
20
46
 
data/README.md CHANGED
@@ -105,6 +105,16 @@ Glyphs.configure do |config|
105
105
  # on every render that goes uncached.
106
106
  config.cache_svgs = true
107
107
 
108
+ # Merge a call's data: hash over the library's configured default data
109
+ # (`default.data` in the icons configuration) instead of replacing it, as the
110
+ # icons gem does. The call's keys win on a conflict; its hash is not mutated.
111
+ # data: nil keeps the defaults, as no data: does; any other non-hash data:
112
+ # renders as the icons gem renders it. Both render uncached. Cached and
113
+ # uncached renders are identical either way. Default: false (the icons gem's behavior).
114
+ # The cache holds the merged markup: call Glyphs.reset_cache! after changing
115
+ # this or the icons gem's default data at runtime (tests).
116
+ config.merge_default_data = false
117
+
108
118
  # How many icons the cache holds (least recently used evicted first). One
109
119
  # cache per Ractor, so a plain threaded server has one. Default: 1024.
110
120
  config.svg_cache_size = 1024
@@ -137,6 +147,8 @@ Active Support; elsewhere, add `gem "ractor-dispatch"`. The markup is the main R
137
147
  the calling Ractor's cache keeps it, so each icon crosses once per cache entry; cache hits never leave the
138
148
  Ractor. The missing-icon policy runs on the main Ractor too, `on_missing_icon` included. A call with an
139
149
  attribute that renders uncached, or any call with `cache_svgs = false`, goes to the main Ractor every time.
150
+ `merge_default_data` reads the default data where the icons gem renders, on the main Ractor, so it adds no
151
+ crossing: a hit merges the call's `data:` in the calling Ractor. With it on, a non-hash `data:` (`nil` included) renders uncached.
140
152
 
141
153
  Without ractor-dispatch, a miss in a non-main Ractor raises `Glyphs::MainRactor::Unavailable`, naming the
142
154
  icon. You can still render on the main Ractor and hand the markup over with `Glyphs.preload_svg`; cache hits
@@ -180,11 +192,48 @@ The scanner resolves them from source, so you rarely need `keep_icons` at all:
180
192
  constant named like one (`ICON = :bell`, `STATUS_ICONS = { .. => :warning }`,
181
193
  including trailing `.freeze`) — are kept for every dynamically-rendered
182
194
  library, so a name declared in one file and rendered from another survives.
195
+ - **Forwarded** — a file that passes a non-literal under a key like an icon
196
+ (`Badge(icon:)`, `Badge(icon: row.first)`) hands a name on to a wrapper
197
+ component, so every icon-name-shaped literal in that file is kept for every
198
+ dynamically-rendered library. `ROWS = [[:bell, 1]]` survives when the row
199
+ reaches `Badge(icon:)`, and `Badge`'s `LucideIcon(@icon)` lives in another file.
183
200
 
184
201
  Only literals are harvested, so the scanner never invents a reference; the
185
202
  worst case is keeping a coincidentally icon-named string, which the post-prune
186
203
  verification tolerates.
187
204
 
205
+ A static scan can't follow every value. A literal passed positionally to a
206
+ wrapper (`m.row("Alerts", :anchor)`) is still pruned, and so is a forwarded
207
+ keyword in a template, which is text-scanned. Nothing fails until production:
208
+ development and test keep every SVG, and the post-prune verification doesn't
209
+ assert harvested names. `raise_on_prunable` closes that gap.
210
+
211
+ ### `raise_on_prunable` — fail the test that renders a prunable icon
212
+
213
+ ```ruby
214
+ # config/environments/test.rb, or an initializer guarded by Rails.env.test?
215
+ Glyphs.configure do |config|
216
+ config.raise_on_prunable = true
217
+ end
218
+ ```
219
+
220
+ With it on, rendering an icon the prune would delete raises
221
+ `Glyphs::PrunableIconError`, naming the library, variant and name. Every page a
222
+ test renders becomes a check, and each failure is a production bug: name the icon
223
+ in an icon position (`icon: :anchor`, an `ICON` constant) or list it in
224
+ `keep_icons`.
225
+
226
+ The guard asks the pruner itself, so the two agree, including on `keep_icons`
227
+ globs and on the libraries a prune leaves alone. That holds for the configuration
228
+ the test runs with: a `keep_icons`, `fallback_icons` or `prune_source_globs` that
229
+ differs by `Rails.env` makes the guard answer for a different keep-set than your
230
+ image build prunes with. The first uncached render scans
231
+ your source, once per process (a parallel test worker scans for itself), and
232
+ cache hits never reach the check. A name that doesn't exist at all still meets
233
+ the missing-icon policy. The default is `false`, which scans nothing, and
234
+ source changed after the scan isn't seen until the process restarts, so keep this
235
+ in the test environment.
236
+
188
237
  ### `keep_icons` — the last-resort escape hatch
189
238
 
190
239
  For names a static scan genuinely can't see — read from a database, an ENV var,
@@ -18,14 +18,20 @@ module Glyphs
18
18
  # cache_svgs: memoize rendered SVGs per [library, variant, name]; the
19
19
  # caller's attributes are applied to the cached markup on every call.
20
20
  # svg_cache_size: how many icons each Ractor's LRU cache holds (1024).
21
+ # merge_default_data: boolean; when true, a caller's data: hash is merged
22
+ # over the library/variant's configured default data (the caller's keys
23
+ # win) instead of replacing it, as the icons gem does. Default: false.
21
24
  # keep_icons: icons the pruner must keep despite no static reference
22
25
  # (dynamic / data-driven names). A flat list of names/globs, or a
23
26
  # { library => [names/globs] } hash. fallback_icons are always kept.
24
27
  # prune_source_globs: extra locations the pruner's scanner text-scans IN
25
28
  # ADDITION to the built-in Ruby/template defaults — e.g. a config file that
26
29
  # names icons. Never replaces the defaults (nil = defaults only).
30
+ # raise_on_prunable: boolean; when true, rendering an icon glyphs:prune_icons
31
+ # would delete raises Glyphs::PrunableIconError. For the test environment:
32
+ # the first uncached render scans the source, once per process.
27
33
  attr_accessor :raise_on_missing, :on_missing_icon, :fallback_icons, :cache_svgs,
28
- :keep_icons, :prune_source_globs
34
+ :keep_icons, :prune_source_globs, :raise_on_prunable, :merge_default_data
29
35
  attr_reader :svg_cache_size
30
36
 
31
37
  def initialize
@@ -36,6 +42,8 @@ module Glyphs
36
42
  @svg_cache_size = DEFAULT_SVG_CACHE_SIZE
37
43
  @keep_icons = []
38
44
  @prune_source_globs = nil
45
+ @raise_on_prunable = false
46
+ @merge_default_data = false
39
47
  end
40
48
 
41
49
  def svg_cache_size=(size)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # config.merge_default_data: merges a library's configured default data under
5
+ # a caller's data: hash, which the icons gem would otherwise replace it with.
6
+ # Reads the icons gem's configuration, so it runs on the main Ractor only
7
+ # (Glyphs.build_svg, which a non-main Ractor reaches through MainRactor).
8
+ # A default data key named like SvgTemplate's slot marker (glyphs-data-slot)
9
+ # would be dropped by the cached data: template, so such defaults are not
10
+ # merged: both paths render as the icons gem does.
11
+ module DefaultData
12
+ SLOT_NAME = SvgTemplate::SLOT_ATTRIBUTE.delete_prefix("data-").freeze
13
+
14
+ class << self
15
+ # The attributes with the default data merged under a data: hash, or
16
+ # without a data: nil (the defaults alone, as with no data:), when the
17
+ # library has default data; else the attributes as given. A new Hash: the
18
+ # caller's is never mutated.
19
+ def merge(attributes, library:, variant:)
20
+ return attributes unless attributes.key?(:data)
21
+
22
+ data = attributes[:data]
23
+ return attributes unless data.nil? || data.is_a?(Hash)
24
+
25
+ defaults = lookup(library:, variant:)
26
+ return attributes unless mergeable?(defaults)
27
+
28
+ data.nil? ? attributes.except(:data) : attributes.merge(data: by_name(defaults, data))
29
+ end
30
+
31
+ private
32
+
33
+ def mergeable?(defaults)
34
+ defaults.is_a?(Hash) && !defaults.empty? && defaults.each_key.none? { |key| key.to_s.tr("_", "-") == SLOT_NAME }
35
+ end
36
+
37
+ # One entry per attribute name, however its key is spelled (:toggle_target,
38
+ # "toggle-target"): at the position its name first appears, with the value
39
+ # it was given last, the caller's after the defaults. That is how a cache
40
+ # hit splices the caller's data over the template's defaults.
41
+ def by_name(defaults, data)
42
+ merged = {}
43
+ defaults.each { |key, value| merged[key.to_s.tr("_", "-")] = value }
44
+ data.each { |key, value| merged[key.to_s.tr("_", "-")] = value }
45
+ merged
46
+ end
47
+
48
+ # The icons gem's own lookup (Icons::Icon::Configurable): the variant, else
49
+ # the library's default_variant, else the global default_variant.
50
+ def lookup(library:, variant:)
51
+ icons = Icons.configuration
52
+ library = library.to_sym
53
+ variant ||= icons.libraries.dig(library, :default_variant) || icons.default_variant
54
+ variant = variant.to_s.empty? ? nil : variant.to_sym
55
+ (icons.libraries.dig(*[library, variant].compact) || {}).dig(:default, :data)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -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,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)
@@ -18,6 +18,9 @@ module Glyphs
18
18
  # A caller's data: replaces the library's default data (the icons gem merges
19
19
  # them that way) and lands where the defaults would have, so data: calls use
20
20
  # a template rendered with DATA_SLOT, whose marker attribute holds that place.
21
+ # With merge_default_data on, that template is rendered with the default data
22
+ # before the marker (Glyphs::DefaultData), and a caller's key that names a
23
+ # default replaces it in place, as rendering the merged hash would.
21
24
  class SvgTemplate
22
25
  DATA_SLOT = { data: { "glyphs-data-slot" => "" }.freeze }.freeze
23
26
  SLOT_ATTRIBUTE = "data-glyphs-data-slot"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Glyphs
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.2"
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
 
@@ -89,8 +92,15 @@ module Glyphs
89
92
  # policy (raise / instrument / fallback) and the per-Ractor cache: one
90
93
  # entry per [library, variant, name], the caller's attributes applied to
91
94
  # the cached markup on every call.
95
+ #
96
+ # With merge_default_data on, the library's default data is merged under a
97
+ # caller's data: hash when the icons gem renders (build_svg, main Ractor
98
+ # only, where its configuration is readable). The data: template is then
99
+ # rendered with the defaults beside the slot marker, so a cache hit splices
100
+ # the caller's keys over them exactly as the merged hash would render. A
101
+ # non-hash data: renders uncached (data: nil keeps the defaults, if any).
92
102
  def svg_for(name:, library:, variant:, attributes:)
93
- return render_svg(name:, library:, variant:, attributes:) unless configuration.cache_svgs
103
+ return render_svg(name:, library:, variant:, attributes:) if uncached?(attributes)
94
104
 
95
105
  data = attributes.key?(:data)
96
106
  template = svg_cache.compute_if_absent([library, variant, name, data], configuration.svg_cache_size) do
@@ -120,6 +130,13 @@ module Glyphs
120
130
 
121
131
  private
122
132
 
133
+ def uncached?(attributes)
134
+ config = configuration
135
+ return true unless config.cache_svgs
136
+
137
+ config.merge_default_data && attributes.key?(:data) && !attributes[:data].is_a?(Hash)
138
+ end
139
+
123
140
  # A non-main Ractor cannot run the icons gem, so it renders on the main
124
141
  # Ractor (see MainRactor); the missing-icon policy runs there too.
125
142
  def render_svg(name:, library:, variant:, attributes:)
@@ -128,13 +145,30 @@ module Glyphs
128
145
  resolve_svg(name:, library:, variant:, attributes:)
129
146
  end
130
147
 
148
+ # Only a miss gets here, and only on the main Ractor. A name with no file is
149
+ # never prunable, so it passes the guard and meets the missing-icon policy.
131
150
  def resolve_svg(name:, library:, variant:, attributes:)
151
+ prune_guard&.check!(name:, library:, variant:)
132
152
  build_svg(name:, library:, variant:, attributes:)
133
153
  rescue Icons::IconNotFound => e
134
154
  handle_missing_icon(e, name:, library:, variant:, attributes:)
135
155
  end
136
156
 
157
+ # nil while raise_on_prunable is off, so that path reads nothing. A racing
158
+ # thread waits for the first scan rather than repeating it. Not @lock: this
159
+ # runs under the SvgCache mutex, and a configure block holding @lock may render.
160
+ def prune_guard
161
+ config = configuration
162
+ return unless config.raise_on_prunable
163
+
164
+ @prune_guard_lock.synchronize do
165
+ @prune_guard = PruneGuard.new(config:) unless @prune_guard&.current?(config)
166
+ @prune_guard
167
+ end
168
+ end
169
+
137
170
  def build_svg(name:, library:, variant:, attributes:)
171
+ attributes = DefaultData.merge(attributes, library:, variant:) if configuration.merge_default_data
138
172
  Icons::Icon.new(name:, library:, variant:, arguments: attributes).svg
139
173
  end
140
174
 
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.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -68,6 +68,7 @@ files:
68
68
  - lib/glyphs/animated_icon.rb
69
69
  - lib/glyphs/box_icon.rb
70
70
  - lib/glyphs/configuration.rb
71
+ - lib/glyphs/default_data.rb
71
72
  - lib/glyphs/feather_icon.rb
72
73
  - lib/glyphs/flag_icon.rb
73
74
  - lib/glyphs/hero_icon.rb
@@ -79,6 +80,8 @@ files:
79
80
  - lib/glyphs/lucide_icon.rb
80
81
  - lib/glyphs/main_ractor.rb
81
82
  - lib/glyphs/phosphor_icon.rb
83
+ - lib/glyphs/prunable_icon_error.rb
84
+ - lib/glyphs/prune_guard.rb
82
85
  - lib/glyphs/prune_report.rb
83
86
  - lib/glyphs/prune_runner.rb
84
87
  - lib/glyphs/radix_icon.rb