glyphs 0.2.3 → 0.3.0

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: 8c8e883474d143ec14502ef27013e1a264c0a89d76b7dd24ef29d2025d96ad5c
4
- data.tar.gz: afad4dcca3bda0162be398c66a7a7bb65c36af8facb9c4b5eb13f5d4d29a1d2c
3
+ metadata.gz: eb4ffc81dcfd96faf6a0f23e3f57780340db27348666e520c7bbc747af04065e
4
+ data.tar.gz: 48e331ff55405e3c054fd0464a50a38ccf75df6d3e837e2a0edf3e3a18894de8
5
5
  SHA512:
6
- metadata.gz: a9c94ace100a67479cb24e793dfd7bc9c71238bfd472f2d29be37076410c27e0cc69d48b2952fad4574bac1e5184770d0b19542284c06edb520fece72d6c1526
7
- data.tar.gz: 8d2cb2a5ca53b92884b28de6a2813326796b034754d5b6f14ab37b4fb7f54c1d49b6deb61dc53d75c0c668e8908447e9a5fdfd8bb71fbb5440c22a5bd9c15b61
6
+ metadata.gz: 382ddd08e2e3cd53ce5164bbb6104bfc46b2c1caf48ac5d39b24e08f3a7594cbd0fa95424793d746c616bc7e4e8621ded6c0af1c5bf4357fa309ded75c49c79a
7
+ data.tar.gz: 1205d90f3d975f3a0880e3768d68c0daf12664c98efb01f715296fd1230dd24fb2af4981e74bd689f98ddf3b7b962c29dc7e3e4dd0125024c49944b312ba8995
data/CHANGELOG.md CHANGED
@@ -1,6 +1,111 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.3.0]
4
+
5
+ ### Changed
6
+
7
+ - **The configuration is frozen once a Rails app has booted.** `Glyphs::Railtie`
8
+ calls `Glyphs.finalize!` from `after_initialize`, after every
9
+ `config/initializers` file; no application code is needed. Writing to it
10
+ after that raises `FrozenError`: `Glyphs.configure` in an app's own
11
+ `config.after_initialize` block (it runs after glyphs' hook) or
12
+ `Glyphs.configuration.<setting> =` at runtime. Configure glyphs in an
13
+ initializer; in tests, `Glyphs.reset_configuration!` starts over with a
14
+ fresh, writable configuration. Outside Rails nothing is frozen until you call
15
+ `Glyphs.finalize!`. Refs #25
16
+
17
+ - **perf: `svg_for` per call, measured with `bench/svg_for.rb`** (Ruby 4.0.6,
18
+ YJIT, arm64 macOS, before → after, same machine, interleaved runs):
19
+
20
+ | case | before | after | allocations |
21
+ |---|---|---|---|
22
+ | cache hit, no attributes | 0.51-0.53 µs | 0.67 µs | 2 → 3 |
23
+ | cache hit, same `class:` | 0.65-0.77 µs | 2.25-2.30 µs | 2 → 15 |
24
+ | distinct `id:` per call | 113-188 µs (a miss every call, cache growing) | 3.3-3.5 µs | 302 → 22 |
25
+ | `cache_svgs = false` | 86-129 µs | unchanged | 297 |
26
+
27
+ A repeated identical render is about 1.6 µs slower per icon (the attributes
28
+ are now merged on every call); renders with varying attributes are 30× or more
29
+ faster and the cache's memory is bounded. Per icon, not per request. `svg_for`
30
+ without attributes returns the cached markup, frozen. Refs #25
31
+
32
+ ### Fixed
33
+
34
+ - **fix: the SVG cache no longer grows without bound.** It was keyed on
35
+ `[library, variant, name, attributes]`, so a dynamic `class:` or `id:` per
36
+ render added an entry per call, for the life of the process: a memory leak on
37
+ any busy page. It is now a bounded LRU (`config.svg_cache_size`, 1024 by
38
+ default) keyed on `[library, variant, name]`; each call's attributes are
39
+ applied to the cached markup in plain Ruby, byte-identical to the icons gem's
40
+ own render (HTML boolean attributes, `href`/`src`/`action` and control
41
+ characters, which libxml2 writes specially, render through the icons gem,
42
+ uncached). Refs #25
43
+
44
+ - **fix: `Glyphs.register_library` and `Glyphs.configure` are serialized.**
45
+ `register_library` was an unguarded `const_defined?`/`const_set`, so threads
46
+ registering the same library at once could each define the constant and get
47
+ different classes back. Both now run under one module lock (a Monitor, so a
48
+ `configure` block may register a library). The configuration is created at
49
+ load instead of on first read. Refs #25
50
+
51
+ - **fix: `Glyphs/IconResolution`'s class-level memos are locked.** The
52
+ icon-directory cache and the warn-once set were lazily built class instance
53
+ variables; they are now read and written under a Mutex. Refs #25
54
+
55
+ ### Added
56
+
57
+ - **`Glyphs.finalize!`**: freezes the configuration and makes every setting
58
+ Ractor-shareable that can be, so `svg_for` can read it from a non-main
59
+ Ractor. Called for you in a Rails app (above); outside Rails, call it at the
60
+ end of boot. It never raises and never freezes an object of the app's: a
61
+ collection setting or a Proc becomes a shareable copy, and a value that
62
+ cannot be shared, such as the README's `on_missing_icon` lambda (it closes
63
+ over the initializer's `self`) or a callable holding a logger, is kept as
64
+ is. The configuration is then readable on the main Ractor only; reading it
65
+ from another Ractor raises `Ractor::IsolationError` until `on_missing_icon`
66
+ is a Ractor-shareable callable (frozen, with nothing unshareable inside) or a
67
+ `Ractor.shareable_proc` (Ruby 4.0+). The SVG cache is per Ractor;
68
+ `Glyphs.preload_svg` seeds a Ractor's cache with markup rendered on the main
69
+ Ractor, since the icons gem cannot render inside another one. It serves calls
70
+ without `data:` whose attributes the cache can apply. Refs #25
71
+
72
+ - **`config.svg_cache_size`** (default 1024; anything but a positive Integer
73
+ raises `ArgumentError`), and `Glyphs.svg_cache`, the current Ractor's cache.
74
+ Refs #25
75
+
76
+ ## [0.2.4] - 2026-07-25
77
+
78
+ ### Fixed
79
+
80
+ - **`Glyphs/IconResolution` no longer fails open on a missing icon directory.**
81
+ A directory that did not exist produced the same empty list as one that
82
+ existed with no SVGs, and both were read as "nothing to check" — so a library
83
+ that was never synced, or a `DefaultVariant` naming a variant the project does
84
+ not have, silently disabled the cop for every call site of that library. Green
85
+ cop, green CI, no validation at all, indefinitely. `load_icons` now returns
86
+ `nil` for an absent directory and `[]` for an empty one; the absent case warns
87
+ once per library/variant, naming the path. The synced-but-empty case still
88
+ passes silently. Refs #8
89
+
90
+ - **Cop options are declared, so RuboCop stops calling them unsupported.**
91
+ `config/default.yml` documented `Libraries` (`Glyphs/IconResolution`) and
92
+ `Mappings` (`Glyphs/LegacyIconHelper`) only in comments, and never mentioned
93
+ `LibraryComponents` (`Glyphs/LegacyIconHelper`, `Glyphs/PreferLibraryComponent`)
94
+ at all. RuboCop derives its supported-parameter list from the keys actually
95
+ present in that file, so it warned `does not support <param> parameter` on
96
+ every run — for four working options, one of which
97
+ `Glyphs/LegacyIconHelper` itself tells you to add. Each is now declared with an
98
+ empty default (a no-op merge), so the options are validated instead of
99
+ reported, with behaviour unchanged. Refs #7
100
+
101
+ ### Added
102
+
103
+ - **`Glyphs/IconResolution` gained a `Strict` option** (default `false`). It
104
+ escalates the missing-icon-directory warning above to an offence, so projects
105
+ that depend on this cop can fail closed in CI instead of trusting a warning
106
+ not to scroll past.
107
+
108
+ ## [0.2.3] - 2026-07-17
4
109
 
5
110
  ### Fixed
6
111
 
@@ -11,6 +116,8 @@
11
116
  `glyphs:prune_icons`. `collect_declaration` now unwraps zero-arg `.freeze`
12
117
  (and parentheses) before walking the value.
13
118
 
119
+ ## [0.2.2] - 2026-07-11
120
+
14
121
  ### Added
15
122
 
16
123
  - **Dynamic icon calls are now resolved from source.** `SourceScanner` no longer
@@ -23,7 +130,7 @@
23
130
  pair keyed `/icon/i`, or a constant named `/ICON/`) are kept for every
24
131
  dynamically-rendered library, closing the cross-file gap (e.g. a notifier
25
132
  `ICON = :bell` constant rendered from a view). Frozen maps
26
- (`ICONS = { … }.freeze`) are included — see Fixed above.
133
+ (`ICONS = { … }.freeze`) are included since 0.2.3.
27
134
 
28
135
  This makes `keep_icons` a last-resort escape hatch (DB/ENV/gem-chrome names)
29
136
  rather than the primary mechanism. Only literals are harvested, so the scanner
data/README.md CHANGED
@@ -96,13 +96,51 @@ Glyphs.configure do |config|
96
96
  heroicons: "question-mark-circle"
97
97
  }
98
98
 
99
- # Memoize rendered SVG strings per [library, variant, name, attributes]. Default: true.
99
+ # Memoize rendered SVGs per [library, variant, name] and whether data: is given;
100
+ # the call's attributes (class:, data:, ...) are applied to the cached markup
101
+ # each time. Attributes the icons gem writes specially (HTML boolean attributes,
102
+ # href/src/action, control characters) render uncached. Default: true.
100
103
  # Note: fallback renders are cached under the original name, so on_missing_icon
101
- # fires once per process per missing icon rather than on every render.
104
+ # fires once per cache entry (with and without data:) while it stays cached, and
105
+ # on every render that goes uncached.
102
106
  config.cache_svgs = true
107
+
108
+ # How many icons the cache holds (least recently used evicted first). One
109
+ # cache per Ractor, so a plain threaded server has one. Default: 1024.
110
+ config.svg_cache_size = 1024
103
111
  end
104
112
  ```
105
113
 
114
+ ## Ractors
115
+
116
+ In a Rails app glyphs finalizes its configuration once the app has booted (`after_initialize`, after every
117
+ `config/initializers` file), with no code of yours: the configuration is frozen and every setting that can be
118
+ is made Ractor-shareable, so `svg_for` can read it from any Ractor. Outside Rails, call `Glyphs.finalize!`
119
+ once at the end of boot, after every `Glyphs.configure`.
120
+
121
+ From then on the configuration is read-only, and writing to it raises `FrozenError`. Configure glyphs in an
122
+ initializer, not in a `config.after_initialize` block of your own (that runs after glyphs' hook). In tests,
123
+ `Glyphs.reset_configuration!` starts over with a fresh, writable configuration.
124
+
125
+ Finalizing never raises and never freezes an object of yours: a collection setting or a Proc becomes a
126
+ shareable copy, and a value that cannot be shared is kept as is. `on_missing_icon` is the usual one: a lambda that closes over the
127
+ initializer's `self` (like the one above) or a callable holding a logger. The configuration then stays
128
+ readable on the main Ractor only, which is all an app without Ractors needs; another Ractor reading it raises
129
+ `Ractor::IsolationError`. To use glyphs from other Ractors, give `on_missing_icon` a Ractor-shareable value: an
130
+ object with a `#call` method that `Ractor.shareable?` accepts (frozen, with nothing unshareable inside) or
131
+ `Ractor.shareable_proc` (Ruby 4.0+).
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):
138
+
139
+ ```ruby
140
+ svg = Glyphs.svg_for(name: "house", library: :lucide, variant: nil, attributes: {}) # main Ractor
141
+ Glyphs.preload_svg(name: "house", library: :lucide, svg:) # in the Ractor
142
+ ```
143
+
106
144
  ## Shrinking icons in Docker
107
145
 
108
146
  `rails g rails_icons:sync` copies a library's **entire** icon set into
@@ -210,6 +248,10 @@ Glyphs/LegacyIconHelper:
210
248
  # Mappings replaces the built-in defaults (_lucide/_phosphor/_hero/_heroicon/_tabler):
211
249
  # Mappings:
212
250
  # _custom: CustomIcon
251
+ # LibraryComponents merges over the built-in library => component map, so
252
+ # `icon("x", library: "customlib")` corrects to `CustomIcon("x")`:
253
+ # LibraryComponents:
254
+ # customlib: CustomIcon
213
255
  ```
214
256
 
215
257
  ### Glyphs/IconResolution
@@ -231,14 +273,38 @@ Glyphs/IconResolution:
231
273
  # Libraries merges over the built-in defaults (component => Dir/DefaultVariant):
232
274
  Libraries:
233
275
  PhosphorIcon: { Dir: phosphor, DefaultVariant: regular }
276
+ # Escalate a missing icon directory from a warning to an offence:
277
+ Strict: false
234
278
  ```
235
279
 
280
+ If the directory a library resolves to does not exist — the library was never synced, or
281
+ `DefaultVariant` names a variant you do not have — the cop cannot validate any of that
282
+ library's call sites. Rather than pass silently, it warns once per library/variant:
283
+
284
+ ```
285
+ [Glyphs/IconResolution] Icon directory `app/assets/svg/icons/phosphor/regular` not found,
286
+ so `PhosphorIcon` names are not validated. Sync the library or fix `IconsPath`/`Libraries`.
287
+ ```
288
+
289
+ Set `Strict: true` to make that an offence instead, so CI fails closed rather than reporting
290
+ green while checking nothing. A directory that exists but ships no SVGs is not a
291
+ misconfiguration and stays silent either way.
292
+
236
293
  ### Glyphs/PreferLibraryComponent
237
294
 
238
295
  ```ruby
239
296
  Icon(:house, library: :lucide) # => LucideIcon(:house)
240
297
  ```
241
298
 
299
+ ```yaml
300
+ Glyphs/PreferLibraryComponent:
301
+ Include:
302
+ - app/**/*.rb
303
+ # LibraryComponents merges over the built-in library => component map:
304
+ # LibraryComponents:
305
+ # customlib: CustomIcon
306
+ ```
307
+
242
308
  ## Migrating an app off icon helpers
243
309
 
244
310
  1. Add `gem "glyphs"`, `include Glyphs` where the helpers used to be included.
data/config/default.yml CHANGED
@@ -3,10 +3,13 @@ Glyphs/LegacyIconHelper:
3
3
  Enabled: true
4
4
  VersionAdded: '0.1.0'
5
5
  SafeAutoCorrect: true
6
- # Mappings replaces the built-in defaults when set:
7
- # Mappings:
6
+ # Replaces the built-in helper => component defaults when set, e.g.
8
7
  # _lucide: LucideIcon
9
8
  # _heroicon: HeroIcon
9
+ Mappings: {}
10
+ # Merges over the built-in library => component map, e.g.
11
+ # customlib: CustomIcon
12
+ LibraryComponents: {}
10
13
  DefaultLibraryComponent: HeroIcon
11
14
 
12
15
  Glyphs/IconResolution:
@@ -14,12 +17,18 @@ Glyphs/IconResolution:
14
17
  Enabled: true
15
18
  VersionAdded: '0.1.0'
16
19
  IconsPath: app/assets/svg/icons
17
- # Libraries merges over the built-in defaults:
18
- # Libraries:
20
+ # Merges over the built-in component => directory/variant defaults, e.g.
19
21
  # PhosphorIcon: { Dir: phosphor, DefaultVariant: light }
22
+ Libraries: {}
23
+ # An icon directory that does not exist warns once per library/variant.
24
+ # Strict turns that warning into an offence so CI fails closed.
25
+ Strict: false
20
26
 
21
27
  Glyphs/PreferLibraryComponent:
22
28
  Description: 'Prefer library-specific components over generic Icon(..., library: ...).'
23
29
  Enabled: true
24
30
  VersionAdded: '0.1.0'
25
31
  SafeAutoCorrect: true
32
+ # Merges over the built-in library => component map, e.g.
33
+ # customlib: CustomIcon
34
+ LibraryComponents: {}
@@ -8,12 +8,16 @@ module Glyphs
8
8
  heroicons: "question-mark-circle"
9
9
  }.freeze
10
10
 
11
+ DEFAULT_SVG_CACHE_SIZE = 1024
12
+
11
13
  # raise_on_missing: boolean; when true, Icons::IconNotFound is re-raised.
12
14
  # Defaults to true in local Rails environments (and outside Rails).
13
15
  # on_missing_icon: optional callable(error, name:, library:, variant:),
14
16
  # invoked before the fallback renders when not raising.
15
17
  # fallback_icons: { library => icon_name } rendered instead of a missing icon.
16
- # cache_svgs: memoize rendered SVG strings per [library, variant, name, attributes].
18
+ # cache_svgs: memoize rendered SVGs per [library, variant, name]; the
19
+ # caller's attributes are applied to the cached markup on every call.
20
+ # svg_cache_size: how many icons each Ractor's LRU cache holds (1024).
17
21
  # keep_icons: icons the pruner must keep despite no static reference
18
22
  # (dynamic / data-driven names). A flat list of names/globs, or a
19
23
  # { library => [names/globs] } hash. fallback_icons are always kept.
@@ -22,14 +26,54 @@ module Glyphs
22
26
  # names icons. Never replaces the defaults (nil = defaults only).
23
27
  attr_accessor :raise_on_missing, :on_missing_icon, :fallback_icons, :cache_svgs,
24
28
  :keep_icons, :prune_source_globs
29
+ attr_reader :svg_cache_size
25
30
 
26
31
  def initialize
27
32
  @raise_on_missing = defined?(Rails) ? Rails.env.local? : true
28
33
  @on_missing_icon = nil
29
34
  @fallback_icons = DEFAULT_FALLBACK_ICONS.dup
30
35
  @cache_svgs = true
36
+ @svg_cache_size = DEFAULT_SVG_CACHE_SIZE
31
37
  @keep_icons = []
32
38
  @prune_source_globs = nil
33
39
  end
40
+
41
+ def svg_cache_size=(size)
42
+ unless size.is_a?(Integer) && size.positive?
43
+ raise ArgumentError, "svg_cache_size must be a positive Integer, got #{size.inspect}"
44
+ end
45
+
46
+ @svg_cache_size = size
47
+ end
48
+
49
+ # Freezes the configuration and makes every setting Ractor-shareable that
50
+ # can be, so svg_for can read it from a non-main Ractor. Glyphs.finalize!
51
+ # calls it (in a Rails app, Glyphs::Railtie does after boot). Never raises
52
+ # and never freezes an object the app still holds: a collection or a Proc
53
+ # becomes a shareable copy, and a value that cannot be shared (an
54
+ # on_missing_icon lambda closing over self, a callable holding a logger) is
55
+ # kept as is, which leaves the configuration readable on the main Ractor only.
56
+ def finalize!
57
+ return self if frozen?
58
+
59
+ if defined?(::Ractor)
60
+ instance_variables.each { |ivar| instance_variable_set(ivar, shareable(instance_variable_get(ivar))) }
61
+ end
62
+ freeze
63
+ end
64
+
65
+ private
66
+
67
+ def shareable(value)
68
+ return value if ::Ractor.shareable?(value)
69
+
70
+ case value
71
+ when ::Proc then ::Ractor.make_shareable(value.dup) # Ractor's copy: cannot copy a Proc; #dup can
72
+ when ::Hash, ::Array, ::String then ::Ractor.make_shareable(value, copy: true)
73
+ else value # an app's object: freezing it in place would freeze what it refers to (its logger) too
74
+ end
75
+ rescue StandardError # Ractor::Error, or TypeError/ArgumentError for what cannot be copied or shared
76
+ value
77
+ end
34
78
  end
35
79
  end
@@ -11,7 +11,7 @@ module Glyphs
11
11
  # The library/variant tables are sourced from the `icons` gem wherever
12
12
  # possible (`default_variant_for`) so they never drift from what actually
13
13
  # resolves at render time.
14
- class IconReference < Data.define(:library, :variant, :name) # rubocop:disable Style/DataInheritance
14
+ class IconReference < Data.define(:library, :variant, :name)
15
15
  # Subclassed (not the block form) so the constants below are namespaced under
16
16
  # IconReference — constants defined inside a `Data.define do…end` block leak
17
17
  # to the enclosing scope instead of nesting under the class.
@@ -44,13 +44,16 @@ module Glyphs
44
44
  "_tabler" => :tabler
45
45
  }.freeze
46
46
 
47
+ # Component name (`"LucideIcon"`) => library symbol (`:lucide`). Built at
48
+ # load, frozen, so it is shareable and never lazily (re)computed.
49
+ COMPONENT_TO_LIBRARY = LIBRARY_TO_COMPONENT.to_h { |library, component| [component, library] }.freeze
50
+
47
51
  # Raw `iconify lucide--house` class strings.
48
52
  ICONIFY_PATTERN = /\biconify\s+(lucide|phosphor|heroicons)--([a-z0-9-]+)/
49
53
 
50
54
  class << self
51
- # Component name (`"LucideIcon"`) => library symbol (`:lucide`).
52
55
  def component_to_library
53
- @component_to_library ||= LIBRARY_TO_COMPONENT.to_h { |library, component| [component, library] }.freeze
56
+ COMPONENT_TO_LIBRARY
54
57
  end
55
58
 
56
59
  def legacy_helpers
@@ -61,7 +64,7 @@ module Glyphs
61
64
  # symbol, or nil when the method isn't an icon call.
62
65
  def library_for(method_name)
63
66
  name = method_name.to_s
64
- component_to_library[name] || LEGACY_HELPERS[name]
67
+ COMPONENT_TO_LIBRARY[name] || LEGACY_HELPERS[name]
65
68
  end
66
69
 
67
70
  # The library's default variant, as configured in the icons gem. Returns a
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Loaded only when Rails is present (see the guard in lib/glyphs.rb). It
5
+ # exposes the icon-pruning rake task to consuming apps and finalizes the
6
+ # configuration after boot. Kept a Railtie, not an Engine: glyphs contributes
7
+ # no routes, views, or migrations.
7
8
  class Railtie < ::Rails::Railtie
8
9
  rake_tasks do
9
10
  load File.expand_path("../tasks/glyphs.rake", __dir__)
10
11
  end
12
+
13
+ # Once every initializer has run (and so every Glyphs.configure), freeze the
14
+ # configuration for Ractors, with no code of the app's. This is the hook
15
+ # config.after_initialize registers (Rails::Railtie::Configuration#after_initialize);
16
+ # calling it directly keeps the Railtie from being instantiated at require
17
+ # (Glyphs::Railtie.@instance), which Rails otherwise does only at boot.
18
+ ActiveSupport.on_load(:after_initialize, yield: true) { Glyphs.finalize! }
11
19
  end
12
20
  end
@@ -18,7 +18,7 @@ module Glyphs
18
18
  LintRoller::About.new(
19
19
  name: "glyphs",
20
20
  version: Glyphs::VERSION,
21
- homepage: "https://github.com/mhenrixon/glyphs",
21
+ homepage: "https://github.com/zoolutions/glyphs",
22
22
  description: "Icon-name validation and legacy icon-helper autocorrection for Glyphs components."
23
23
  )
24
24
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # Per-Ractor lazy storage: `Store.fetch(:glyphs_svg_cache) { SvgCache.new }`.
5
+ #
6
+ # Adapted from conformance-kit's ConformanceKit::Store (MIT, same author),
7
+ # which glyphs cannot depend on at runtime. On CRuby (3.4+, the gem's floor)
8
+ # this is `Ractor.store_if_absent`: atomic, one value per Ractor. Engines
9
+ # without it (TruffleRuby, JRuby) get one Mutex-guarded Hash, created at load
10
+ # and only there, so on CRuby no unshareable module state exists at all.
11
+ module Store
12
+ class << self
13
+ def fetch(key, &)
14
+ return ::Ractor.store_if_absent(key, &) if native?
15
+
16
+ @fallback_lock.synchronize do
17
+ @fallback_values.fetch(key) { @fallback_values[key] = yield }
18
+ end
19
+ end
20
+
21
+ def native?
22
+ defined?(::Ractor) ? ::Ractor.respond_to?(:store_if_absent) : false
23
+ end
24
+ end
25
+
26
+ unless native?
27
+ @fallback_lock = Mutex.new
28
+ # Only on engines without Ractor: guarded by @fallback_lock, never read unlocked.
29
+ @fallback_values = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # A bounded LRU of SvgTemplates, one per Ractor (see Glyphs.svg_cache). The
5
+ # threads of a Ractor share it, so every access holds the instance's Mutex;
6
+ # the size limit is passed per write, so a reconfigured svg_cache_size
7
+ # applies to a cache that already exists.
8
+ class SvgCache
9
+ def initialize
10
+ @entries = {}
11
+ @lock = Mutex.new
12
+ end
13
+
14
+ # The cached value for `key` (never nil), marked most recently used; on a
15
+ # miss, the block's value, stored. Either way the cache is trimmed to
16
+ # `limit` entries, so a lowered svg_cache_size applies on the next call. The
17
+ # block runs under the lock, so a missing icon is rendered (and reported) once.
18
+ def compute_if_absent(key, limit)
19
+ @lock.synchronize do
20
+ hit = @entries[key]
21
+ insert(key, hit.nil? ? yield : hit, limit)
22
+ end
23
+ end
24
+
25
+ def store(key, value, limit)
26
+ @lock.synchronize { insert(key, value, limit) }
27
+ end
28
+
29
+ def size
30
+ @lock.synchronize { @entries.size }
31
+ end
32
+
33
+ def clear
34
+ @lock.synchronize { @entries.clear }
35
+ self
36
+ end
37
+
38
+ private
39
+
40
+ def insert(key, value, limit)
41
+ @entries.delete(key)
42
+ @entries[key] = value
43
+ @entries.shift while @entries.size > limit
44
+ value
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glyphs
4
+ # A rendered <svg>, split once into its opening tag's attributes and the
5
+ # rest, so a cache hit applies a caller's attributes without the File.read
6
+ # and Nokogiri pass of a fresh render (Icons::Icon#svg).
7
+ #
8
+ # The attributes go through the icons gem's own Icons::Icon::Attributes
9
+ # (class token lists, nested data:/aria: hashes, `_` to `-`), collected into a
10
+ # Hash instead of a Nokogiri node, and are written the way libxml2 writes
11
+ # them: an attribute already on the tag is replaced in place, a new one is
12
+ # appended, `&`, `<` and `>` are escaped, and a value holding `"` but no `'`
13
+ # is single-quoted. What libxml2 writes specially (HTML boolean attributes,
14
+ # href/src/action, control characters) is not merged: #render returns nil and
15
+ # the caller renders it uncached. spec/glyphs/svg_template_spec.rb holds the
16
+ # output byte-for-byte to the icons gem's.
17
+ #
18
+ # A caller's data: replaces the library's default data (the icons gem merges
19
+ # them that way) and lands where the defaults would have, so data: calls use
20
+ # a template rendered with DATA_SLOT, whose marker attribute holds that place.
21
+ class SvgTemplate
22
+ DATA_SLOT = { data: { "glyphs-data-slot" => "" }.freeze }.freeze
23
+ SLOT_ATTRIBUTE = "data-glyphs-data-slot"
24
+
25
+ OPENING_TAG = /\A<svg((?:\s+[^\s=>]+(?:="[^"]*"|='[^']*')?)*)\s*>/
26
+ ATTRIBUTE = /\s+(([^\s=>]+)(?:="[^"]*"|='[^']*')?)/
27
+ ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;" }.freeze
28
+ ESCAPABLE = /[&<>"]/
29
+ # libxml2 writes HTML boolean attributes without their value and
30
+ # percent-encodes URI attributes.
31
+ SPECIAL_NAMES = %w[
32
+ checked compact declare defer disabled ismap multiple nohref noresize noshade nowrap readonly selected
33
+ href src action
34
+ ].to_h { |name| [name, true] }.freeze
35
+ UPPERCASE = /[A-Z]/
36
+ PLAIN_NAME = %r{\A[^\s"'<>/=\x00-\x1F\x7F]+\z}
37
+ CONTROL_CHARACTER = /[\x00-\x08\x0B\x0C\x0E-\x1F]/
38
+
39
+ attr_reader :svg
40
+
41
+ # `data_slot: true` for markup rendered with DATA_SLOT as its data:.
42
+ def initialize(svg, data_slot: false)
43
+ @svg = svg.frozen? ? svg : svg.dup.freeze
44
+ match = OPENING_TAG.match(@svg)
45
+ @rest = match&.post_match.freeze
46
+ @pairs = match && parse(match[1])
47
+ @slot = data_slot ? slot_index : nil
48
+ @pairs = nil if data_slot && @slot.nil? # no marker: nothing to merge onto
49
+ freeze
50
+ end
51
+
52
+ # The SVG with `attributes` applied, or nil when they cannot be merged
53
+ # exactly (the caller then renders them through the icons gem).
54
+ def render(attributes)
55
+ return @svg if attributes.empty?
56
+ return unless mergeable?(attributes)
57
+
58
+ pairs = merge(@pairs.dup, attributes)
59
+ build(pairs) if pairs
60
+ end
61
+
62
+ private
63
+
64
+ def parse(attributes)
65
+ attributes.scan(ATTRIBUTE).map { |fragment, name| [name.freeze, " #{fragment}".freeze].freeze }.freeze
66
+ end
67
+
68
+ def slot_index
69
+ @pairs&.index { |name, _| name == SLOT_ATTRIBUTE }
70
+ end
71
+
72
+ # A data: call needs the data: template, any other call the plain one.
73
+ def mergeable?(attributes)
74
+ !@pairs.nil? && attributes.key?(:data) == !@slot.nil?
75
+ end
76
+
77
+ def merge(pairs, attributes)
78
+ return if @slot && !splice_data(pairs, attributes.slice(:data))
79
+
80
+ merged = collect(@slot ? attributes.except(:data) : attributes)
81
+ return unless merged
82
+
83
+ merged.each { |name, fragment| assign(pairs, name, fragment) }
84
+ pairs
85
+ end
86
+
87
+ # Puts the data: attributes where the slot marker is, in order, replacing
88
+ # any already on the tag, then drops the marker. A caller's own attribute
89
+ # named like the marker would be dropped with it: that call renders uncached.
90
+ def splice_data(pairs, data)
91
+ merged = collect(data)
92
+ return false if merged.nil? || merged.any? { |name, _| name == SLOT_ATTRIBUTE }
93
+
94
+ slot = @slot
95
+ merged.each do |name, fragment|
96
+ index = pairs.index { |pair_name, _| pair_name == name }
97
+ next pairs[index] = [name, fragment] if index
98
+
99
+ pairs.insert(slot, [name, fragment])
100
+ slot += 1
101
+ end
102
+ pairs.delete_at(slot)
103
+ end
104
+
105
+ def assign(pairs, name, fragment)
106
+ index = pairs.index { |pair_name, _| pair_name == name }
107
+ if index
108
+ pairs[index] = [name, fragment]
109
+ else
110
+ pairs << [name, fragment]
111
+ end
112
+ end
113
+
114
+ # [[name, fragment], ...] in the order the icons gem would set them, or nil
115
+ # if any of them is one libxml2 writes specially.
116
+ def collect(attributes)
117
+ collected = {}
118
+ Icons::Icon::Attributes.new(arguments: attributes).attach(to: collected)
119
+ collected.map do |name, value|
120
+ name = name.to_s
121
+ value = value.to_s
122
+ return nil unless plain?(name, value)
123
+
124
+ [name, fragment(name, value)]
125
+ end
126
+ end
127
+
128
+ def plain?(name, value)
129
+ PLAIN_NAME.match?(name) && !special?(name) && value.valid_encoding? &&
130
+ (value.ascii_only? || value.encoding == Encoding::UTF_8) && !CONTROL_CHARACTER.match?(value)
131
+ end
132
+
133
+ def special?(name)
134
+ SPECIAL_NAMES.key?(name) || (UPPERCASE.match?(name) && SPECIAL_NAMES.key?(name.downcase))
135
+ end
136
+
137
+ def fragment(name, value)
138
+ return " #{name}=\"#{value}\"" unless ESCAPABLE.match?(value)
139
+
140
+ if value.include?('"') && !value.include?("'")
141
+ " #{name}='#{value.gsub(/[&<>]/, ESCAPES)}'"
142
+ else
143
+ " #{name}=\"#{value.gsub(ESCAPABLE, ESCAPES)}\""
144
+ end
145
+ end
146
+
147
+ def build(pairs)
148
+ svg = +"<svg"
149
+ pairs.each { |pair| svg << pair.last }
150
+ svg << ">" << @rest
151
+ end
152
+ end
153
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Glyphs
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/glyphs.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
3
4
  require "phlex"
4
5
  require "icons"
5
6
  require "zeitwerk"
@@ -32,21 +33,32 @@ Icons.config
32
33
  module Glyphs
33
34
  extend Phlex::Kit
34
35
 
35
- @svg_cache = {}
36
- @svg_cache_mutex = Mutex.new
36
+ # Guards the boot-time writers (configure, register_library, finalize!). A
37
+ # Monitor, so a configure block may call register_library. Main Ractor only:
38
+ # readers never take it, so svg_for runs in any Ractor once finalized.
39
+ @lock = Monitor.new
40
+ @configuration = Configuration.new
37
41
 
38
42
  class << self
43
+ # The current configuration: a plain read, shareable after finalize!.
44
+ attr_reader :configuration
45
+
39
46
  def configure
40
- yield(configuration) if block_given?
47
+ @lock.synchronize { yield(@configuration) if block_given? }
41
48
  configuration
42
49
  end
43
50
 
44
- def configuration
45
- @configuration ||= Configuration.new
51
+ def reset_configuration!
52
+ @lock.synchronize { @configuration = Configuration.new }
46
53
  end
47
54
 
48
- def reset_configuration!
49
- @configuration = Configuration.new
55
+ # Freezes the configuration so svg_for can run inside a non-main Ractor
56
+ # (see Configuration#finalize!; never raises). Glyphs::Railtie calls it once
57
+ # a Rails app has booted; outside Rails, call it at the end of boot, after
58
+ # every configure. reset_configuration! (tests) starts over with a fresh one.
59
+ def finalize!
60
+ @lock.synchronize { @configuration.finalize! }
61
+ self
50
62
  end
51
63
 
52
64
  # Defines a `Glyphs::Icon` subclass for a custom icon library and exposes it
@@ -59,31 +71,51 @@ module Glyphs
59
71
  library = library.to_sym
60
72
  name = component.to_sym
61
73
 
62
- if const_defined?(name, false)
63
- existing = const_get(name, false)
64
- return existing if existing < Icon && library == existing::LIBRARY
74
+ @lock.synchronize do
75
+ if const_defined?(name, false)
76
+ existing = const_get(name, false)
77
+ return existing if existing < Icon && library == existing::LIBRARY
65
78
 
66
- raise ArgumentError, "Glyphs::#{name} is already defined and does not render the #{library} library"
67
- end
79
+ raise ArgumentError, "Glyphs::#{name} is already defined and does not render the #{library} library"
80
+ end
68
81
 
69
- klass = Class.new(Icon)
70
- klass.const_set(:LIBRARY, library)
71
- const_set(name, klass)
82
+ klass = Class.new(Icon)
83
+ klass.const_set(:LIBRARY, library)
84
+ const_set(name, klass)
85
+ end
72
86
  end
73
87
 
74
88
  # Returns the SVG markup for an icon, applying the configured missing-icon
75
- # policy (raise / instrument / fallback) and an optional per-process cache.
89
+ # policy (raise / instrument / fallback) and the per-Ractor cache: one
90
+ # entry per [library, variant, name], the caller's attributes applied to
91
+ # the cached markup on every call.
76
92
  def svg_for(name:, library:, variant:, attributes:)
77
93
  return render_svg(name:, library:, variant:, attributes:) unless configuration.cache_svgs
78
94
 
79
- key = [library, variant, name, attributes]
80
- @svg_cache_mutex.synchronize do
81
- @svg_cache[key] ||= render_svg(name:, library:, variant:, attributes:)
95
+ data = attributes.key?(:data)
96
+ template = svg_cache.compute_if_absent([library, variant, name, data], configuration.svg_cache_size) do
97
+ markup = render_svg(name:, library:, variant:, attributes: data ? SvgTemplate::DATA_SLOT : {})
98
+ SvgTemplate.new(markup, data_slot: data)
82
99
  end
100
+ template.render(attributes) || render_svg(name:, library:, variant:, attributes:)
101
+ end
102
+
103
+ # Seeds this Ractor's cache with markup rendered elsewhere, e.g. on the
104
+ # main Ractor (which can run the icons gem and Nokogiri) for a Ractor that
105
+ # cannot. Calls with a data: attribute still render through the icons gem.
106
+ def preload_svg(name:, library:, svg:, variant: nil)
107
+ key = [library.to_sym, variant&.to_sym, name.to_s.tr("_", "-"), false]
108
+ svg_cache.store(key, SvgTemplate.new(svg), configuration.svg_cache_size)
109
+ end
110
+
111
+ # The current Ractor's SvgCache.
112
+ def svg_cache
113
+ Store.fetch(:glyphs_svg_cache) { SvgCache.new }
83
114
  end
84
115
 
116
+ # Empties the current Ractor's cache (other Ractors keep theirs).
85
117
  def reset_cache!
86
- @svg_cache_mutex.synchronize { @svg_cache = {} }
118
+ svg_cache.clear
87
119
  end
88
120
 
89
121
  private
@@ -11,6 +11,11 @@ module RuboCop
11
11
  # calls (`_lucide(:house)`) are validated. A literal `variant:` keyword is
12
12
  # honored; calls with dynamic names or variants are skipped.
13
13
  #
14
+ # A directory that does not exist is a configuration error, not "nothing
15
+ # to check": it warns once per library/variant, or — with `Strict` — adds
16
+ # an offence, so the cop can never quietly validate nothing. A directory
17
+ # that exists but ships no SVGs stays silent.
18
+ #
14
19
  # @example
15
20
  # # bad
16
21
  # LucideIcon(:non_existent_icon)
@@ -51,10 +56,14 @@ module RuboCop
51
56
 
52
57
  ICONIFY_PATTERN = /\biconify\s+(lucide|phosphor|heroicons)--([a-z0-9-]+)/
53
58
 
59
+ DEFAULT_ICONS_PATH = "app/assets/svg/icons"
60
+
54
61
  RAW_MSG = "Use `%{component}(:%{symbol}, class: \"%{rest}\")` instead of raw `iconify %{library}--…` class."
55
62
  RAW_DSTR_MSG = "Use `LucideIcon(name, class: ...)` etc. instead of building a raw " \
56
63
  "`iconify <library>--…` class string."
57
64
  MISSING_MSG = "Icon `%{name}` not found in %{library}/%{variant}. %{suggestion}"
65
+ MISSING_DIR_MSG = "Icon directory `%{path}` not found, so `%{component}` names are not validated. " \
66
+ "Sync the library or fix `IconsPath`/`Libraries`."
58
67
 
59
68
  def on_str(node)
60
69
  return if node.parent&.dstr_type?
@@ -97,7 +106,7 @@ module RuboCop
97
106
  @libraries ||= DEFAULT_LIBRARIES.merge(cop_config["Libraries"] || {})
98
107
  end
99
108
 
100
- def check_call(node, _component, library)
109
+ def check_call(node, component, library)
101
110
  name = literal_name(node.first_argument)
102
111
  return unless name
103
112
 
@@ -105,6 +114,7 @@ module RuboCop
105
114
  return if variant == :dynamic
106
115
 
107
116
  available = available_icons(library["Dir"], variant)
117
+ return report_missing_directory(node, component, library["Dir"], variant) if available.nil?
108
118
  return if available.empty?
109
119
  return if available.include?(name)
110
120
 
@@ -124,6 +134,21 @@ module RuboCop
124
134
  end
125
135
  end
126
136
 
137
+ # The directory the cop was told to read is missing, so every call for
138
+ # this library goes unchecked. Report it rather than pass silently.
139
+ def report_missing_directory(node, component, library_dir, variant)
140
+ message = format(MISSING_DIR_MSG, path: configured_directory(library_dir, variant), component:)
141
+ return add_offense(node.first_argument, message:) if cop_config["Strict"]
142
+
143
+ self.class.warn_once("[Glyphs/IconResolution] #{message}")
144
+ end
145
+
146
+ # Built from the unexpanded `IconsPath` so the message shows the path as
147
+ # the project wrote it, not an absolute machine-specific one.
148
+ def configured_directory(library_dir, variant)
149
+ self.class.directory_for(cop_config["IconsPath"] || DEFAULT_ICONS_PATH, library_dir, variant)
150
+ end
151
+
127
152
  def variant_for(node, library)
128
153
  pair = variant_pair(node)
129
154
  return library["DefaultVariant"] if pair.nil?
@@ -258,24 +283,53 @@ module RuboCop
258
283
  end
259
284
 
260
285
  def icons_base_path
261
- @icons_base_path ||= File.expand_path(cop_config["IconsPath"] || "app/assets/svg/icons", Dir.pwd)
286
+ @icons_base_path ||= File.expand_path(cop_config["IconsPath"] || DEFAULT_ICONS_PATH, Dir.pwd)
262
287
  end
263
288
 
264
289
  def available_icons(library_dir, variant)
265
290
  self.class.available_icons_for(icons_base_path, library_dir, variant)
266
291
  end
267
292
 
293
+ # Guards the two class-level memos below: RuboCop builds a cop instance
294
+ # per file, and a server or LSP process may inspect files on threads.
295
+ @memo_lock = Mutex.new
296
+
268
297
  class << self
298
+ # nil when the directory is absent, [] when it exists but ships no
299
+ # SVGs — the caller has to tell those apart. Memoized with `key?` (not
300
+ # `||=`) so a nil result is not re-probed on every call site.
269
301
  def available_icons_for(base_path, library_dir, variant)
270
- @available_icons_cache ||= {}
271
- @available_icons_cache[[base_path, library_dir, variant]] ||= load_icons(base_path, library_dir, variant)
302
+ key = [base_path, library_dir, variant]
303
+ @memo_lock.synchronize do
304
+ @available_icons_cache ||= {}
305
+ return @available_icons_cache[key] if @available_icons_cache.key?(key)
306
+
307
+ @available_icons_cache[key] = load_icons(directory_for(base_path, library_dir, variant))
308
+ end
309
+ end
310
+
311
+ def directory_for(base_path, library_dir, variant)
312
+ File.join(*[base_path, library_dir, variant].compact.reject { |part| part.to_s.empty? })
313
+ end
314
+
315
+ # RuboCop builds a fresh cop instance per file, so deduplicating the
316
+ # missing-directory warning has to outlive the instance.
317
+ def warn_once(message)
318
+ first = @memo_lock.synchronize do
319
+ @warned_messages ||= {}
320
+ !@warned_messages.key?(message) && (@warned_messages[message] = true)
321
+ end
322
+ warn message if first
323
+ end
324
+
325
+ def reset_warnings!
326
+ @memo_lock.synchronize { @warned_messages = nil }
272
327
  end
273
328
 
274
329
  private
275
330
 
276
- def load_icons(base_path, library_dir, variant)
277
- path = File.join(*[base_path, library_dir, variant].compact.reject { |part| part.to_s.empty? })
278
- return [] unless Dir.exist?(path)
331
+ def load_icons(path)
332
+ return nil unless Dir.exist?(path)
279
333
 
280
334
  Dir.children(path).filter_map { |file| File.basename(file, ".svg") if file.end_with?(".svg") }.sort
281
335
  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.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -85,6 +85,9 @@ files:
85
85
  - lib/glyphs/rubocop.rb
86
86
  - lib/glyphs/sidekick_icon.rb
87
87
  - lib/glyphs/source_scanner.rb
88
+ - lib/glyphs/store.rb
89
+ - lib/glyphs/svg_cache.rb
90
+ - lib/glyphs/svg_template.rb
88
91
  - lib/glyphs/tabler_icon.rb
89
92
  - lib/glyphs/version.rb
90
93
  - lib/glyphs/weather_icon.rb
@@ -93,13 +96,13 @@ files:
93
96
  - lib/rubocop/cop/glyphs/library_call_helpers.rb
94
97
  - lib/rubocop/cop/glyphs/prefer_library_component.rb
95
98
  - lib/tasks/glyphs.rake
96
- homepage: https://github.com/mhenrixon/glyphs
99
+ homepage: https://github.com/zoolutions/glyphs
97
100
  licenses:
98
101
  - MIT
99
102
  metadata:
100
- source_code_uri: https://github.com/mhenrixon/glyphs
101
- changelog_uri: https://github.com/mhenrixon/glyphs/blob/main/CHANGELOG.md
102
- bug_tracker_uri: https://github.com/mhenrixon/glyphs/issues
103
+ source_code_uri: https://github.com/zoolutions/glyphs
104
+ changelog_uri: https://github.com/zoolutions/glyphs/blob/main/CHANGELOG.md
105
+ bug_tracker_uri: https://github.com/zoolutions/glyphs/issues
103
106
  default_lint_roller_plugin: Glyphs::RuboCop::Plugin
104
107
  rubygems_mfa_required: 'true'
105
108
  rdoc_options: []