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 +4 -4
- data/CHANGELOG.md +88 -7
- data/README.md +40 -2
- data/lib/glyphs/configuration.rb +45 -1
- data/lib/glyphs/icon_reference.rb +7 -4
- data/lib/glyphs/railtie.rb +11 -3
- data/lib/glyphs/rubocop.rb +1 -1
- data/lib/glyphs/store.rb +32 -0
- data/lib/glyphs/svg_cache.rb +47 -0
- data/lib/glyphs/svg_template.rb +153 -0
- data/lib/glyphs/version.rb +1 -1
- data/lib/glyphs.rb +52 -20
- data/lib/rubocop/cop/glyphs/icon_resolution.rb +15 -9
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb4ffc81dcfd96faf6a0f23e3f57780340db27348666e520c7bbc747af04065e
|
|
4
|
+
data.tar.gz: 48e331ff55405e3c054fd0464a50a38ccf75df6d3e837e2a0edf3e3a18894de8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 382ddd08e2e3cd53ce5164bbb6104bfc46b2c1caf48ac5d39b24e08f3a7594cbd0fa95424793d746c616bc7e4e8621ded6c0af1c5bf4357fa309ded75c49c79a
|
|
7
|
+
data.tar.gz: 1205d90f3d975f3a0880e3768d68c0daf12664c98efb01f715296fd1230dd24fb2af4981e74bd689f98ddf3b7b962c29dc7e3e4dd0125024c49944b312ba8995
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
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
|
-
|
|
119
|
+
## [0.2.2] - 2026-07-11
|
|
36
120
|
|
|
37
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/glyphs/configuration.rb
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/glyphs/railtie.rb
CHANGED
|
@@ -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).
|
|
5
|
-
#
|
|
6
|
-
# Railtie, not an Engine: glyphs contributes
|
|
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
|
data/lib/glyphs/rubocop.rb
CHANGED
|
@@ -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/
|
|
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
|
data/lib/glyphs/store.rb
ADDED
|
@@ -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 = { "&" => "&", "<" => "<", ">" => ">", '"' => """ }.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
|
data/lib/glyphs/version.rb
CHANGED
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
|
-
|
|
36
|
-
|
|
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
|
|
45
|
-
@configuration
|
|
51
|
+
def reset_configuration!
|
|
52
|
+
@lock.synchronize { @configuration = Configuration.new }
|
|
46
53
|
end
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
79
|
+
raise ArgumentError, "Glyphs::#{name} is already defined and does not render the #{library} library"
|
|
80
|
+
end
|
|
68
81
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
+
@memo_lock.synchronize do
|
|
304
|
+
@available_icons_cache ||= {}
|
|
305
|
+
return @available_icons_cache[key] if @available_icons_cache.key?(key)
|
|
301
306
|
|
|
302
|
-
|
|
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
|
-
@
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
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/
|
|
99
|
+
homepage: https://github.com/zoolutions/glyphs
|
|
97
100
|
licenses:
|
|
98
101
|
- MIT
|
|
99
102
|
metadata:
|
|
100
|
-
source_code_uri: https://github.com/
|
|
101
|
-
changelog_uri: https://github.com/
|
|
102
|
-
bug_tracker_uri: https://github.com/
|
|
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: []
|