glyphs 0.2.4 → 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: 5635cb76266896b0fb2d2e661844d27400be25b6e4a1190271279c7a4331517c
4
- data.tar.gz: d8afd8c82d444afd15e951eba3aae5d1daaa0fce73efd7ac86fe725b9ce6dd09
3
+ metadata.gz: eb4ffc81dcfd96faf6a0f23e3f57780340db27348666e520c7bbc747af04065e
4
+ data.tar.gz: 48e331ff55405e3c054fd0464a50a38ccf75df6d3e837e2a0edf3e3a18894de8
5
5
  SHA512:
6
- metadata.gz: dffab87c9e9862dad15042135dc4c2ef7fb5785dd848210538b6e63c5263b8be6fc4a9b6c5276ac8ee7523fe239e08bb63007aff45af8ba52721fd52d774a946
7
- data.tar.gz: 492c537c6ebb87013ec9795406b78cfb6b50e06328e2cc9b8b072a0c359e71d575a6d4853450c66b7d3c526492679ad156ef858a122cf848f4856ae957b14e55
6
+ metadata.gz: 382ddd08e2e3cd53ce5164bbb6104bfc46b2c1caf48ac5d39b24e08f3a7594cbd0fa95424793d746c616bc7e4e8621ded6c0af1c5bf4357fa309ded75c49c79a
7
+ data.tar.gz: 1205d90f3d975f3a0880e3768d68c0daf12664c98efb01f715296fd1230dd24fb2af4981e74bd689f98ddf3b7b962c29dc7e3e4dd0125024c49944b312ba8995
data/CHANGELOG.md CHANGED
@@ -1,6 +1,79 @@
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
4
77
 
5
78
  ### Fixed
6
79
 
@@ -25,6 +98,17 @@
25
98
  empty default (a no-op merge), so the options are validated instead of
26
99
  reported, with behaviour unchanged. Refs #7
27
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
109
+
110
+ ### Fixed
111
+
28
112
  - **Declaration harvest unwraps trailing `.freeze`.** `ICONS = { "x" => :car }.freeze`
29
113
  (and `%i[a b].freeze`) used to yield a Prism `CallNode`, so hash/array values
30
114
  were never collected. Cross-file dynamics (`PhosphorIcon(@icon)` in a shared
@@ -32,12 +116,9 @@
32
116
  `glyphs:prune_icons`. `collect_declaration` now unwraps zero-arg `.freeze`
33
117
  (and parentheses) before walking the value.
34
118
 
35
- ### Added
119
+ ## [0.2.2] - 2026-07-11
36
120
 
37
- - **`Glyphs/IconResolution` gained a `Strict` option** (default `false`). It
38
- escalates the missing-icon-directory warning above to an offence, so projects
39
- that depend on this cop can fail closed in CI instead of trusting a warning
40
- not to scroll past.
121
+ ### Added
41
122
 
42
123
  - **Dynamic icon calls are now resolved from source.** `SourceScanner` no longer
43
124
  silently skips `LucideIcon(some_var)` / `PhosphorIcon(tile[:icon])` — it harvests
@@ -49,7 +130,7 @@
49
130
  pair keyed `/icon/i`, or a constant named `/ICON/`) are kept for every
50
131
  dynamically-rendered library, closing the cross-file gap (e.g. a notifier
51
132
  `ICON = :bell` constant rendered from a view). Frozen maps
52
- (`ICONS = { … }.freeze`) are included — see Fixed above.
133
+ (`ICONS = { … }.freeze`) are included since 0.2.3.
53
134
 
54
135
  This makes `keep_icons` a last-resort escape hatch (DB/ENV/gem-chrome names)
55
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
@@ -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.4"
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
@@ -290,16 +290,22 @@ module RuboCop
290
290
  self.class.available_icons_for(icons_base_path, library_dir, variant)
291
291
  end
292
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
+
293
297
  class << self
294
298
  # nil when the directory is absent, [] when it exists but ships no
295
299
  # SVGs — the caller has to tell those apart. Memoized with `key?` (not
296
300
  # `||=`) so a nil result is not re-probed on every call site.
297
301
  def available_icons_for(base_path, library_dir, variant)
298
- @available_icons_cache ||= {}
299
302
  key = [base_path, library_dir, variant]
300
- return @available_icons_cache[key] if @available_icons_cache.key?(key)
303
+ @memo_lock.synchronize do
304
+ @available_icons_cache ||= {}
305
+ return @available_icons_cache[key] if @available_icons_cache.key?(key)
301
306
 
302
- @available_icons_cache[key] = load_icons(directory_for(base_path, library_dir, variant))
307
+ @available_icons_cache[key] = load_icons(directory_for(base_path, library_dir, variant))
308
+ end
303
309
  end
304
310
 
305
311
  def directory_for(base_path, library_dir, variant)
@@ -309,15 +315,15 @@ module RuboCop
309
315
  # RuboCop builds a fresh cop instance per file, so deduplicating the
310
316
  # missing-directory warning has to outlive the instance.
311
317
  def warn_once(message)
312
- @warned_messages ||= {}
313
- return if @warned_messages.key?(message)
314
-
315
- @warned_messages[message] = true
316
- warn 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
317
323
  end
318
324
 
319
325
  def reset_warnings!
320
- @warned_messages = nil
326
+ @memo_lock.synchronize { @warned_messages = nil }
321
327
  end
322
328
 
323
329
  private
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glyphs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
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: []