glyphs 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +49 -0
- data/lib/glyphs/configuration.rb +10 -1
- data/lib/glyphs/icon_pruner.rb +131 -0
- data/lib/glyphs/icon_reference.rb +91 -0
- data/lib/glyphs/prune_report.rb +61 -0
- data/lib/glyphs/prune_runner.rb +103 -0
- data/lib/glyphs/railtie.rb +12 -0
- data/lib/glyphs/source_scanner.rb +227 -0
- data/lib/glyphs/version.rb +1 -1
- data/lib/glyphs.rb +7 -0
- data/lib/rubocop/cop/glyphs/library_call_helpers.rb +4 -15
- data/lib/tasks/glyphs.rake +17 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dffbe50e2d6b992be18142abb575887e4b11ee8b26ad2afec2a3d94f0ed418cd
|
|
4
|
+
data.tar.gz: 59e4a5c55154ae1075f0c4cf0c7a3b2a1f767fc12416dd48b85d39b0bbed119a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ef3a208721a23a5ab7272de80644228046b5f5bc42834d4f2f5f59f7da78bf141775cf6f8a192243abdcfdebdc444eab3bb87df33409fa429cc4571c76c4e0e5
|
|
7
|
+
data.tar.gz: cdc2a32800aa6c3a503dbccd0c8ee524e120fe1d418ba417bcf905f00e283fcc56c24be29dfec7f2dc4e0a6c57b268d2cb2577d9f5cc9b9b201483a6c0c93f2b
|
data/README.md
CHANGED
|
@@ -103,6 +103,55 @@ Glyphs.configure do |config|
|
|
|
103
103
|
end
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
## Shrinking icons in Docker
|
|
107
|
+
|
|
108
|
+
`rails g rails_icons:sync` copies a library's **entire** icon set into
|
|
109
|
+
`app/assets/svg/icons/<library>/<variant>/` — often thousands of SVGs, of which
|
|
110
|
+
an app uses a handful. `glyphs:prune_icons` deletes the unreferenced ones so a
|
|
111
|
+
Docker image ships only what it renders, while the committed repo keeps the full
|
|
112
|
+
set for development.
|
|
113
|
+
|
|
114
|
+
It scans your source (`app/**/*.rb`, `lib/**/*.rb` with a real parser, plus
|
|
115
|
+
`.erb/.haml/.slim` text) for `LucideIcon(:house)`, legacy helpers, generic
|
|
116
|
+
`Icon(:x, library: :lucide)`, and `iconify lucide--house` class strings, then
|
|
117
|
+
keeps that set **plus** two safety nets: the `keep_icons` allowlist (for
|
|
118
|
+
dynamic/data-driven names a static scan can't see) and every configured
|
|
119
|
+
`fallback_icons` (a pruned fallback would 500 on the next missing icon).
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# config/initializers/glyphs.rb
|
|
123
|
+
Glyphs.configure do |config|
|
|
124
|
+
# Icons referenced dynamically (names from config, a DB, or a gem's chrome).
|
|
125
|
+
# Flat list or per-library hash; names or fnmatch globs.
|
|
126
|
+
config.keep_icons = %w[menu palette search circle-*]
|
|
127
|
+
# config.keep_icons = { lucide: %w[menu palette], phosphor: %w[lock] }
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Preview (dry run — deletes nothing):
|
|
133
|
+
bin/rails glyphs:prune_icons
|
|
134
|
+
|
|
135
|
+
# Delete (both env vars required — a deliberate opt-in so it never fires
|
|
136
|
+
# accidentally on a developer's working tree):
|
|
137
|
+
PRUNE=1 GLYPHS_PRUNE_ICONS=1 bin/rails glyphs:prune_icons
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Run it in the image build, **after** `assets:precompile` and before the final
|
|
141
|
+
stage copies the app — so the deletions land in the image but never in a
|
|
142
|
+
developer's checkout:
|
|
143
|
+
|
|
144
|
+
```dockerfile
|
|
145
|
+
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
|
146
|
+
RUN SECRET_KEY_BASE_DUMMY=1 PRUNE=1 GLYPHS_PRUNE_ICONS=1 ./bin/rails glyphs:prune_icons
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
After deleting, the task **verifies** every kept icon still resolves and exits
|
|
150
|
+
non-zero if not — a bad prune (e.g. a missing allowlist entry) fails the build
|
|
151
|
+
instead of shipping broken icons. The bundled `animated` library and any
|
|
152
|
+
`custom_path` library are left untouched, and it refuses to empty a library
|
|
153
|
+
whose keep-set is empty.
|
|
154
|
+
|
|
106
155
|
## RuboCop cops
|
|
107
156
|
|
|
108
157
|
Add the plugin (RuboCop >= 1.72):
|
data/lib/glyphs/configuration.rb
CHANGED
|
@@ -14,13 +14,22 @@ module Glyphs
|
|
|
14
14
|
# invoked before the fallback renders when not raising.
|
|
15
15
|
# fallback_icons: { library => icon_name } rendered instead of a missing icon.
|
|
16
16
|
# cache_svgs: memoize rendered SVG strings per [library, variant, name, attributes].
|
|
17
|
-
|
|
17
|
+
# keep_icons: icons the pruner must keep despite no static reference
|
|
18
|
+
# (dynamic / data-driven names). A flat list of names/globs, or a
|
|
19
|
+
# { library => [names/globs] } hash. fallback_icons are always kept.
|
|
20
|
+
# prune_source_globs: extra locations the pruner's scanner text-scans IN
|
|
21
|
+
# ADDITION to the built-in Ruby/template defaults — e.g. a config file that
|
|
22
|
+
# names icons. Never replaces the defaults (nil = defaults only).
|
|
23
|
+
attr_accessor :raise_on_missing, :on_missing_icon, :fallback_icons, :cache_svgs,
|
|
24
|
+
:keep_icons, :prune_source_globs
|
|
18
25
|
|
|
19
26
|
def initialize
|
|
20
27
|
@raise_on_missing = defined?(Rails) ? Rails.env.local? : true
|
|
21
28
|
@on_missing_icon = nil
|
|
22
29
|
@fallback_icons = DEFAULT_FALLBACK_ICONS.dup
|
|
23
30
|
@cache_svgs = true
|
|
31
|
+
@keep_icons = []
|
|
32
|
+
@prune_source_globs = nil
|
|
24
33
|
end
|
|
25
34
|
end
|
|
26
35
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Glyphs
|
|
6
|
+
# Deletes synced SVG icons that no source references, keeping the union of:
|
|
7
|
+
# scanned references, the configured keep_icons allowlist (names or globs,
|
|
8
|
+
# flat or per-library), and the configured fallback_icons (always — a pruned
|
|
9
|
+
# fallback would make every future missing-icon 500).
|
|
10
|
+
#
|
|
11
|
+
# Operates only on `icons_root` (`app/assets/svg/icons`), skips the bundled
|
|
12
|
+
# `animated` library, never deletes non-.svg files, and refuses to empty a
|
|
13
|
+
# library whose computed keep-set is empty (guards against a mis-scan wiping a
|
|
14
|
+
# whole library). Returns a PruneReport.
|
|
15
|
+
class IconPruner
|
|
16
|
+
ANIMATED = :animated
|
|
17
|
+
|
|
18
|
+
def initialize(icons_root:, references:, keep_icons: [], fallback_icons: {}, dry_run: false)
|
|
19
|
+
@icons_root = icons_root
|
|
20
|
+
@references = references
|
|
21
|
+
@keep_icons = keep_icons
|
|
22
|
+
@fallback_icons = fallback_icons
|
|
23
|
+
@dry_run = dry_run
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
stats = []
|
|
28
|
+
deleted_names = []
|
|
29
|
+
|
|
30
|
+
grouped_files.each do |(library, variant), files|
|
|
31
|
+
# Guard on library-specific evidence of use, NOT on the merged keep-set:
|
|
32
|
+
# a flat keep_icons list is non-empty for every library, so keying the
|
|
33
|
+
# guard off keep.empty? would let it wipe an entirely-unreferenced
|
|
34
|
+
# library down to whatever the flat list happens to match.
|
|
35
|
+
unless library_used?(library)
|
|
36
|
+
warn "[Glyphs::IconPruner] refusing to prune #{library}/#{variant || '.'} — " \
|
|
37
|
+
"no references, fallback, or per-library keep_icons for #{library}"
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
keep = keep_names_for(library, variant)
|
|
42
|
+
stat = prune_group(library, variant, files, keep, deleted_names)
|
|
43
|
+
stats << stat
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
PruneReport.new(stats:, deleted_names:, dry_run: @dry_run)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# { [library, variant] => [absolute svg paths] }, excluding the animated lib.
|
|
52
|
+
def grouped_files
|
|
53
|
+
svg_files.group_by { |path| library_and_variant(path) }.reject { |(library, _), _| library == ANIMATED }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def svg_files
|
|
57
|
+
Dir.glob([File.join(@icons_root, "*", "*", "*.svg"), File.join(@icons_root, "*", "*.svg")]).uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Derives [library_sym, variant_or_nil] from an svg path under icons_root.
|
|
61
|
+
# Two dirs deep => library/variant/name.svg; one dir deep => library/name.svg.
|
|
62
|
+
def library_and_variant(path)
|
|
63
|
+
relative = path.delete_prefix("#{@icons_root}/")
|
|
64
|
+
parts = relative.split("/")
|
|
65
|
+
if parts.size >= 3
|
|
66
|
+
[parts[0].to_sym, parts[1]]
|
|
67
|
+
else
|
|
68
|
+
[parts[0].to_sym, nil]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prune_group(library, variant, files, keep, deleted_names)
|
|
73
|
+
kept = 0
|
|
74
|
+
deleted = 0
|
|
75
|
+
bytes_freed = 0
|
|
76
|
+
|
|
77
|
+
files.each do |path|
|
|
78
|
+
name = File.basename(path, ".svg")
|
|
79
|
+
if keep_file?(name, keep)
|
|
80
|
+
kept += 1
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
deleted += 1
|
|
85
|
+
bytes_freed += File.size(path)
|
|
86
|
+
deleted_names << name
|
|
87
|
+
File.delete(path) unless @dry_run
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
PruneReport::LibraryStat.new(library:, variant:, kept:, deleted:, bytes_freed:)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def keep_file?(name, keep)
|
|
94
|
+
keep.any? { |pattern| pattern == name || File.fnmatch?(pattern, name) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The set of names/globs to keep for a (library, variant): references for
|
|
98
|
+
# this exact library+variant, the library's keep_icons entries, and the
|
|
99
|
+
# library's fallback icon.
|
|
100
|
+
def keep_names_for(library, variant)
|
|
101
|
+
names = Set.new
|
|
102
|
+
@references.each do |reference|
|
|
103
|
+
names << reference.name if reference.library == library && reference.variant == variant
|
|
104
|
+
end
|
|
105
|
+
names.merge(keep_icons_for(library))
|
|
106
|
+
fallback = @fallback_icons[library]
|
|
107
|
+
names << fallback if fallback
|
|
108
|
+
names
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def keep_icons_for(library)
|
|
112
|
+
case @keep_icons
|
|
113
|
+
when Hash then Array(@keep_icons[library] || @keep_icons[library.to_s])
|
|
114
|
+
else Array(@keep_icons)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Whether a library has library-specific evidence it's in use: any scanned
|
|
119
|
+
# reference, a configured fallback, or a per-library keep_icons entry. A flat
|
|
120
|
+
# keep_icons list is deliberately NOT evidence — it applies to every library,
|
|
121
|
+
# so counting it would defeat the wipe guard for unreferenced libraries.
|
|
122
|
+
def library_used?(library)
|
|
123
|
+
return true if @references.any? { |reference| reference.library == library }
|
|
124
|
+
return true if @fallback_icons[library]
|
|
125
|
+
|
|
126
|
+
# A per-library keep_icons entry counts only if it actually lists icons —
|
|
127
|
+
# an empty array (`{ tabler: [] }`) is not evidence of use.
|
|
128
|
+
@keep_icons.is_a?(Hash) && keep_icons_for(library).any?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glyphs
|
|
4
|
+
# A single (library, variant, name) icon reference discovered in source, with
|
|
5
|
+
# the shared library/variant resolution tables the scanner and the RuboCop
|
|
6
|
+
# cops both rely on.
|
|
7
|
+
#
|
|
8
|
+
# `library` is an icons-gem library symbol (`:lucide`), `variant` is a string
|
|
9
|
+
# or nil (variant-less libraries), and `name` is dasherized (`"circle-check"`).
|
|
10
|
+
#
|
|
11
|
+
# The library/variant tables are sourced from the `icons` gem wherever
|
|
12
|
+
# possible (`default_variant_for`) so they never drift from what actually
|
|
13
|
+
# resolves at render time.
|
|
14
|
+
class IconReference < Data.define(:library, :variant, :name) # rubocop:disable Style/DataInheritance
|
|
15
|
+
# Subclassed (not the block form) so the constants below are namespaced under
|
|
16
|
+
# IconReference — constants defined inside a `Data.define do…end` block leak
|
|
17
|
+
# to the enclosing scope instead of nesting under the class.
|
|
18
|
+
#
|
|
19
|
+
# Maps every Glyphs library component to its icons-gem library symbol. This
|
|
20
|
+
# is the canonical map; `RuboCop::Cop::Glyphs::LibraryCallHelpers` reads it
|
|
21
|
+
# so the cops and the pruner agree on the full set of libraries.
|
|
22
|
+
LIBRARY_TO_COMPONENT = {
|
|
23
|
+
lucide: "LucideIcon",
|
|
24
|
+
phosphor: "PhosphorIcon",
|
|
25
|
+
heroicons: "HeroIcon",
|
|
26
|
+
tabler: "TablerIcon",
|
|
27
|
+
feather: "FeatherIcon",
|
|
28
|
+
boxicons: "BoxIcon",
|
|
29
|
+
flags: "FlagIcon",
|
|
30
|
+
hugeicons: "HugeIcon",
|
|
31
|
+
linear: "LinearIcon",
|
|
32
|
+
radix: "RadixIcon",
|
|
33
|
+
sidekickicons: "SidekickIcon",
|
|
34
|
+
weather: "WeatherIcon",
|
|
35
|
+
animated: "AnimatedIcon"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Legacy icon helpers (`_lucide(:house)`) the scanner also recognizes.
|
|
39
|
+
LEGACY_HELPERS = {
|
|
40
|
+
"_lucide" => :lucide,
|
|
41
|
+
"_phosphor" => :phosphor,
|
|
42
|
+
"_hero" => :heroicons,
|
|
43
|
+
"_heroicon" => :heroicons,
|
|
44
|
+
"_tabler" => :tabler
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Raw `iconify lucide--house` class strings.
|
|
48
|
+
ICONIFY_PATTERN = /\biconify\s+(lucide|phosphor|heroicons)--([a-z0-9-]+)/
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Component name (`"LucideIcon"`) => library symbol (`:lucide`).
|
|
52
|
+
def component_to_library
|
|
53
|
+
@component_to_library ||= LIBRARY_TO_COMPONENT.to_h { |library, component| [component, library] }.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def legacy_helpers
|
|
57
|
+
LEGACY_HELPERS
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Resolves a called method name (component or legacy helper) to a library
|
|
61
|
+
# symbol, or nil when the method isn't an icon call.
|
|
62
|
+
def library_for(method_name)
|
|
63
|
+
name = method_name.to_s
|
|
64
|
+
component_to_library[name] || LEGACY_HELPERS[name]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The library's default variant, as configured in the icons gem. Returns a
|
|
68
|
+
# string (`"outline"`), or nil for variant-less libraries.
|
|
69
|
+
def default_variant_for(library)
|
|
70
|
+
options = Icons.config.libraries[library.to_sym]
|
|
71
|
+
normalize_variant(options&.default_variant)
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Mirrors the icons gem's variant→path handling so a scanned reference
|
|
77
|
+
# points at the same file that renders: an empty string (a documented
|
|
78
|
+
# rails_icons override) and `"."` (the variant-less convention) both mean
|
|
79
|
+
# "no variant subdirectory" and normalize to nil. See the icons gem's
|
|
80
|
+
# Icons::Icon::Configurable#set_variant and Icons::Icon::FilePath#parts.
|
|
81
|
+
def normalize_variant(variant)
|
|
82
|
+
return if variant.nil?
|
|
83
|
+
|
|
84
|
+
string = variant.to_s
|
|
85
|
+
return if string.empty? || string == "."
|
|
86
|
+
|
|
87
|
+
string
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glyphs
|
|
4
|
+
# The result of an IconPruner run: per-(library, variant) counts, a sample of
|
|
5
|
+
# deleted names, and a human-readable summary. Value object, no side effects.
|
|
6
|
+
class PruneReport
|
|
7
|
+
LibraryStat = Data.define(:library, :variant, :kept, :deleted, :bytes_freed)
|
|
8
|
+
|
|
9
|
+
SAMPLE_LIMIT = 20
|
|
10
|
+
|
|
11
|
+
attr_reader :stats, :deleted_names, :dry_run
|
|
12
|
+
|
|
13
|
+
def initialize(stats:, deleted_names:, dry_run:)
|
|
14
|
+
@stats = stats
|
|
15
|
+
@deleted_names = deleted_names
|
|
16
|
+
@dry_run = dry_run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def deleted_count = stats.sum(&:deleted)
|
|
20
|
+
def kept_count = stats.sum(&:kept)
|
|
21
|
+
def bytes_freed = stats.sum(&:bytes_freed)
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
lines = [headline, *stat_lines]
|
|
25
|
+
lines << sample_line if deleted_names.any?
|
|
26
|
+
lines << " Re-run with PRUNE=1 GLYPHS_PRUNE_ICONS=1 to delete." if dry_run
|
|
27
|
+
lines.join("\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def headline
|
|
33
|
+
prefix = dry_run ? "[dry-run] " : ""
|
|
34
|
+
verb = dry_run ? "Would prune" : "Pruned"
|
|
35
|
+
"#{prefix}#{verb} #{deleted_count} icons, kept #{kept_count}, freed #{human_bytes(bytes_freed)}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stat_lines
|
|
39
|
+
stats.sort_by { |stat| [stat.library.to_s, stat.variant.to_s] }.map do |stat|
|
|
40
|
+
" #{stat.library}/#{stat.variant || '.'}: #{stat.deleted} deleted, #{stat.kept} kept"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sample_line
|
|
45
|
+
shown = deleted_names.first(SAMPLE_LIMIT).join(", ")
|
|
46
|
+
extra = deleted_names.size - SAMPLE_LIMIT
|
|
47
|
+
extra.positive? ? " e.g. #{shown} … and #{extra} more" : " e.g. #{shown}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def human_bytes(bytes)
|
|
51
|
+
units = %w[B KB MB GB]
|
|
52
|
+
size = bytes.to_f
|
|
53
|
+
unit = units.shift
|
|
54
|
+
while size >= 1024 && units.any?
|
|
55
|
+
size /= 1024
|
|
56
|
+
unit = units.shift
|
|
57
|
+
end
|
|
58
|
+
unit == "B" ? "#{bytes} B" : format("%.2f %s", size, unit)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glyphs
|
|
4
|
+
# Wires configuration → SourceScanner → IconPruner and (when deleting) verifies
|
|
5
|
+
# every kept icon still resolves on disk. Thin orchestrator so the rake task is
|
|
6
|
+
# a one-liner and the whole flow is unit-testable.
|
|
7
|
+
#
|
|
8
|
+
# Glyphs::PruneRunner.call(dry_run: false) # scans Rails.root, prunes, verifies
|
|
9
|
+
class PruneRunner
|
|
10
|
+
# Raised when a kept icon (static ref, keep_icons, or fallback) is missing
|
|
11
|
+
# after a prune — turns a bad prune into a failed build, never a 500.
|
|
12
|
+
class VerificationError < StandardError; end
|
|
13
|
+
|
|
14
|
+
def self.call(**)
|
|
15
|
+
new(**).call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(root: default_root, icons_root: default_icons_root, dry_run: true, config: Glyphs.configuration)
|
|
19
|
+
@root = root
|
|
20
|
+
@icons_root = icons_root
|
|
21
|
+
@dry_run = dry_run
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
report = pruner.call
|
|
27
|
+
verify! unless @dry_run
|
|
28
|
+
report
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Asserts every kept icon resolves to a file on disk. Raises
|
|
32
|
+
# VerificationError listing the first few misses.
|
|
33
|
+
def verify!
|
|
34
|
+
missing = expected_files.reject { |path| File.exist?(path) }
|
|
35
|
+
return if missing.empty?
|
|
36
|
+
|
|
37
|
+
names = missing.first(10).map { |path| relative(path) }
|
|
38
|
+
raise VerificationError,
|
|
39
|
+
"#{missing.size} kept icon(s) missing after prune: #{names.join(', ')}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :config
|
|
45
|
+
|
|
46
|
+
def pruner
|
|
47
|
+
@pruner ||= IconPruner.new(
|
|
48
|
+
icons_root: @icons_root,
|
|
49
|
+
references:,
|
|
50
|
+
keep_icons: config.keep_icons,
|
|
51
|
+
fallback_icons: config.fallback_icons,
|
|
52
|
+
dry_run: @dry_run
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def references
|
|
57
|
+
@references ||= SourceScanner.new(root: @root, **scanner_options).call
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scanner_options
|
|
61
|
+
globs = config.prune_source_globs
|
|
62
|
+
globs ? { extra_globs: Array(globs) } : {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Files that MUST exist after a prune: every scanned reference and every
|
|
66
|
+
# configured fallback icon, restricted to libraries actually synced under
|
|
67
|
+
# icons_root (the pruner only touches those — a fallback for an un-synced
|
|
68
|
+
# library is irrelevant and must not fail the build). keep_icons globs are
|
|
69
|
+
# advisory (they may match nothing), so they're not asserted here.
|
|
70
|
+
def expected_files
|
|
71
|
+
fallback_refs = config.fallback_icons.map do |library, name|
|
|
72
|
+
IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
(references.to_a + fallback_refs)
|
|
76
|
+
.select { |reference| library_present?(reference.library) }
|
|
77
|
+
.map { |reference| file_for(reference) }
|
|
78
|
+
.uniq
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def library_present?(library)
|
|
82
|
+
Dir.exist?(File.join(@icons_root, library.to_s))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def file_for(reference)
|
|
86
|
+
parts = [reference.library.to_s, reference.variant, "#{reference.name}.svg"].compact
|
|
87
|
+
File.join(@icons_root, *parts)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def relative(path)
|
|
91
|
+
path.delete_prefix("#{@icons_root}/")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def default_root
|
|
95
|
+
defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def default_icons_root
|
|
99
|
+
base = Icons.config.base_path
|
|
100
|
+
File.join(base.to_s, Icons.config.icons_path.to_s)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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.
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
rake_tasks do
|
|
9
|
+
load File.expand_path("../tasks/glyphs.rake", __dir__)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Glyphs
|
|
6
|
+
# Scans an application's source for statically-known icon references and
|
|
7
|
+
# returns a Set of IconReference. Ruby/Phlex files are parsed with Prism (a
|
|
8
|
+
# real AST); templates (.erb/.haml/.slim) are text-scanned for icon calls and
|
|
9
|
+
# `iconify` class strings.
|
|
10
|
+
#
|
|
11
|
+
# Dynamic names/variants/libraries are silently skipped — they can't be known
|
|
12
|
+
# statically and are covered by the pruner's keep-list. A file that fails to
|
|
13
|
+
# parse warns and is skipped rather than aborting the scan.
|
|
14
|
+
class SourceScanner
|
|
15
|
+
DEFAULT_RUBY_GLOBS = ["app/**/*.rb", "lib/**/*.rb"].freeze
|
|
16
|
+
DEFAULT_TEMPLATE_GLOBS = ["app/**/*.erb", "app/**/*.haml", "app/**/*.slim"].freeze
|
|
17
|
+
|
|
18
|
+
# Template (.erb/.haml/.slim) text scanning. Prism can't parse these, so we
|
|
19
|
+
# match icon calls textually: the method, the literal first-argument name,
|
|
20
|
+
# and the argument tail (to pull out `variant:` and, for the generic
|
|
21
|
+
# `icon`/`Icon` helper, `library:`/`from:`). `Icon`/`icon` are included here
|
|
22
|
+
# (they're handled by the AST visitor for .rb files) so the generic
|
|
23
|
+
# rails_icons view helper is caught in templates too.
|
|
24
|
+
TEMPLATE_CALL_NAMES = (
|
|
25
|
+
IconReference::LIBRARY_TO_COMPONENT.values +
|
|
26
|
+
IconReference::LEGACY_HELPERS.keys +
|
|
27
|
+
%w[Icon icon]
|
|
28
|
+
).freeze
|
|
29
|
+
# The tail runs to the end of the line or the ERB/Slim tag close (`%>`,
|
|
30
|
+
# `-%>`) — NOT to the first `)`, which would truncate `variant:`/`from:` sitting
|
|
31
|
+
# after a nested call like `class: cn("a")`. Over-capturing the tail only ever
|
|
32
|
+
# ADDS a keep (never drops a reference), so it errs safe.
|
|
33
|
+
TEMPLATE_CALL_PATTERN = /
|
|
34
|
+
\b(#{Regexp.union(TEMPLATE_CALL_NAMES)})
|
|
35
|
+
\(?\s*[:"']([a-z0-9_-]+)['"]? # first arg is a literal symbol or string
|
|
36
|
+
([^\n]*?) # argument tail (rest of the logical line)
|
|
37
|
+
(?=-?%>|\n|\z)
|
|
38
|
+
/x
|
|
39
|
+
TEMPLATE_VARIANT_PATTERN = /\bvariant:\s*[:"']?([a-z0-9_.-]*)/
|
|
40
|
+
TEMPLATE_LIBRARY_PATTERN = /\b(?:library|from):\s*[:"']([a-z0-9_-]+)/
|
|
41
|
+
GENERIC_TEMPLATE_CALLS = %w[Icon icon].freeze
|
|
42
|
+
|
|
43
|
+
# `extra_globs` are additional locations to text-scan on top of the built-in
|
|
44
|
+
# Ruby and template defaults (never replacing them) — e.g. a config file that
|
|
45
|
+
# names icons. They're scanned like templates (regex, not AST).
|
|
46
|
+
def initialize(root:, ruby_globs: DEFAULT_RUBY_GLOBS, template_globs: DEFAULT_TEMPLATE_GLOBS, extra_globs: [])
|
|
47
|
+
@root = root
|
|
48
|
+
@ruby_globs = ruby_globs
|
|
49
|
+
@template_globs = template_globs
|
|
50
|
+
@extra_globs = Array(extra_globs)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call
|
|
54
|
+
references = Set.new
|
|
55
|
+
ruby_files.each { |path| scan_ruby(path, references) }
|
|
56
|
+
(template_files - ruby_files).each { |path| scan_template(path, references) }
|
|
57
|
+
references
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :root
|
|
63
|
+
|
|
64
|
+
def ruby_files
|
|
65
|
+
glob(@ruby_globs)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def template_files
|
|
69
|
+
glob(@template_globs + @extra_globs)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def glob(patterns)
|
|
73
|
+
patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.uniq
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scan_ruby(path, references)
|
|
77
|
+
result = Prism.parse_file(path)
|
|
78
|
+
CallVisitor.new(references).visit(result.value)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def scan_template(path, references)
|
|
84
|
+
text = File.read(path)
|
|
85
|
+
scan_iconify(text, references)
|
|
86
|
+
text.scan(TEMPLATE_CALL_PATTERN) do |method_name, name, tail|
|
|
87
|
+
add_template_call(references, method_name, name, tail)
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Records a template icon call, reading `variant:` (and, for the generic
|
|
94
|
+
# icon/Icon helper, `library:`/`from:`) out of the captured argument tail so
|
|
95
|
+
# a variant-bearing template call resolves to the same file it renders.
|
|
96
|
+
def add_template_call(references, method_name, name, tail)
|
|
97
|
+
library = template_library(method_name, tail)
|
|
98
|
+
return unless library
|
|
99
|
+
|
|
100
|
+
references << IconReference.new(
|
|
101
|
+
library:,
|
|
102
|
+
variant: template_variant(tail, library),
|
|
103
|
+
name: name.to_s.tr("_", "-")
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A present-but-empty `variant:` (`variant: ""` / `variant: :"."`) means
|
|
108
|
+
# no-variant — mirror the AST path: default ONLY when the keyword is absent.
|
|
109
|
+
def template_variant(tail, library)
|
|
110
|
+
match = tail.match(TEMPLATE_VARIANT_PATTERN)
|
|
111
|
+
return IconReference.default_variant_for(library) unless match
|
|
112
|
+
|
|
113
|
+
IconReference.normalize_variant(match[1])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def template_library(method_name, tail)
|
|
117
|
+
return IconReference.library_for(method_name) unless GENERIC_TEMPLATE_CALLS.include?(method_name)
|
|
118
|
+
|
|
119
|
+
match = tail.match(TEMPLATE_LIBRARY_PATTERN)
|
|
120
|
+
match && match[1].to_sym
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def scan_iconify(text, references)
|
|
124
|
+
text.scan(IconReference::ICONIFY_PATTERN) do |library, name|
|
|
125
|
+
library = library.to_sym
|
|
126
|
+
references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Walks the Prism AST collecting icon references from call nodes.
|
|
131
|
+
class CallVisitor < Prism::Visitor
|
|
132
|
+
GENERIC_CALLS = %i[Icon icon].freeze
|
|
133
|
+
|
|
134
|
+
def initialize(references)
|
|
135
|
+
@references = references
|
|
136
|
+
super()
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def visit_call_node(node)
|
|
140
|
+
record(node) if node.receiver.nil?
|
|
141
|
+
super
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def visit_string_node(node)
|
|
145
|
+
node.unescaped.scan(IconReference::ICONIFY_PATTERN) do |library, name|
|
|
146
|
+
library = library.to_sym
|
|
147
|
+
references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
|
|
148
|
+
end
|
|
149
|
+
super
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
attr_reader :references
|
|
155
|
+
|
|
156
|
+
def record(node)
|
|
157
|
+
method = node.name.to_s
|
|
158
|
+
if GENERIC_CALLS.include?(node.name)
|
|
159
|
+
record_generic(node)
|
|
160
|
+
elsif IconReference.library_for(method)
|
|
161
|
+
record_component(node, IconReference.library_for(method))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def record_component(node, library)
|
|
166
|
+
name = literal_string(first_argument(node))
|
|
167
|
+
return unless name
|
|
168
|
+
|
|
169
|
+
variant = variant_for(node, library)
|
|
170
|
+
return if variant == :dynamic
|
|
171
|
+
|
|
172
|
+
references << IconReference.new(library:, variant:, name:)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def record_generic(node)
|
|
176
|
+
library = literal_symbol_or_string(keyword_argument(node, %i[library from]))
|
|
177
|
+
return unless library
|
|
178
|
+
|
|
179
|
+
name = literal_string(first_argument(node))
|
|
180
|
+
return unless name
|
|
181
|
+
|
|
182
|
+
variant = variant_for(node, library.to_sym)
|
|
183
|
+
return if variant == :dynamic
|
|
184
|
+
|
|
185
|
+
references << IconReference.new(library: library.to_sym, variant:, name:)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def variant_for(node, library)
|
|
189
|
+
value_node = keyword_argument(node, [:variant])
|
|
190
|
+
return IconReference.default_variant_for(library) unless value_node
|
|
191
|
+
|
|
192
|
+
value = literal_symbol_or_string(value_node)
|
|
193
|
+
return :dynamic unless value
|
|
194
|
+
|
|
195
|
+
IconReference.normalize_variant(value)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def first_argument(node)
|
|
199
|
+
node.arguments&.arguments&.first
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Returns the value node for the first matching keyword, or nil.
|
|
203
|
+
def keyword_argument(node, keys)
|
|
204
|
+
pair = keyword_pairs(node).find do |element|
|
|
205
|
+
element.key.is_a?(Prism::SymbolNode) && keys.include?(element.key.unescaped.to_sym)
|
|
206
|
+
end
|
|
207
|
+
pair&.value
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def keyword_pairs(node)
|
|
211
|
+
args = node.arguments&.arguments || []
|
|
212
|
+
hash = args.find { |arg| arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode) }
|
|
213
|
+
hash ? hash.elements.grep(Prism::AssocNode) : []
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def literal_string(value_node)
|
|
217
|
+
literal_symbol_or_string(value_node)&.tr("_", "-")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Returns the literal String for a symbol/string node, or nil when the
|
|
221
|
+
# node is dynamic (a variable, method call, interpolation, ...).
|
|
222
|
+
def literal_symbol_or_string(value_node)
|
|
223
|
+
value_node.unescaped if value_node.is_a?(Prism::SymbolNode) || value_node.is_a?(Prism::StringNode)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/glyphs/version.rb
CHANGED
data/lib/glyphs.rb
CHANGED
|
@@ -11,8 +11,15 @@ loader.ignore("#{__dir__}/glyphs/version.rb")
|
|
|
11
11
|
# RuboCop process (see the bottom of this file).
|
|
12
12
|
loader.ignore("#{__dir__}/glyphs/rubocop.rb")
|
|
13
13
|
loader.ignore("#{__dir__}/rubocop")
|
|
14
|
+
# The Railtie is required conditionally below (only under Rails), and the rake
|
|
15
|
+
# tasks aren't Ruby constants — keep both out of Zeitwerk's managed set.
|
|
16
|
+
loader.ignore("#{__dir__}/glyphs/railtie.rb")
|
|
17
|
+
loader.ignore("#{__dir__}/tasks")
|
|
14
18
|
loader.setup
|
|
15
19
|
|
|
20
|
+
# Expose the icon-pruning rake task to consuming Rails apps.
|
|
21
|
+
require_relative "glyphs/railtie" if defined?(Rails::Railtie)
|
|
22
|
+
|
|
16
23
|
# Ensure the icons gem is configured even outside Rails. Inside Rails the
|
|
17
24
|
# rails_icons engine calls `Icons.configure` with the app's settings; the
|
|
18
25
|
# configuration is memoized, so this never clobbers app configuration.
|
|
@@ -9,21 +9,10 @@ module RuboCop
|
|
|
9
9
|
module LibraryCallHelpers
|
|
10
10
|
LIBRARY_KEYS = %i[library from].freeze
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"tabler" => "TablerIcon",
|
|
17
|
-
"feather" => "FeatherIcon",
|
|
18
|
-
"boxicons" => "BoxIcon",
|
|
19
|
-
"flags" => "FlagIcon",
|
|
20
|
-
"hugeicons" => "HugeIcon",
|
|
21
|
-
"linear" => "LinearIcon",
|
|
22
|
-
"radix" => "RadixIcon",
|
|
23
|
-
"sidekickicons" => "SidekickIcon",
|
|
24
|
-
"weather" => "WeatherIcon",
|
|
25
|
-
"animated" => "AnimatedIcon"
|
|
26
|
-
}.freeze
|
|
12
|
+
# Library name string => component name, keyed by the icons-gem library
|
|
13
|
+
# name (`"lucide"`). Derived from the canonical map in
|
|
14
|
+
# `Glyphs::IconReference` so the cops and the icon pruner never drift.
|
|
15
|
+
LIBRARY_TO_COMPONENT = ::Glyphs::IconReference::LIBRARY_TO_COMPONENT.transform_keys(&:to_s).freeze
|
|
27
16
|
|
|
28
17
|
private
|
|
29
18
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :glyphs do
|
|
4
|
+
desc "Prune synced icons not referenced in source. Dry-run unless PRUNE=1 GLYPHS_PRUNE_ICONS=1."
|
|
5
|
+
task prune_icons: :environment do
|
|
6
|
+
commit = ENV["PRUNE"] == "1" && ENV["GLYPHS_PRUNE_ICONS"] == "1"
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
report = Glyphs::PruneRunner.call(dry_run: !commit)
|
|
10
|
+
rescue Glyphs::PruneRunner::VerificationError => e
|
|
11
|
+
warn "[glyphs:prune_icons] #{e.message}"
|
|
12
|
+
abort "[glyphs:prune_icons] prune verification failed — aborting."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
puts report
|
|
16
|
+
end
|
|
17
|
+
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.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -73,12 +73,18 @@ files:
|
|
|
73
73
|
- lib/glyphs/hero_icon.rb
|
|
74
74
|
- lib/glyphs/huge_icon.rb
|
|
75
75
|
- lib/glyphs/icon.rb
|
|
76
|
+
- lib/glyphs/icon_pruner.rb
|
|
77
|
+
- lib/glyphs/icon_reference.rb
|
|
76
78
|
- lib/glyphs/linear_icon.rb
|
|
77
79
|
- lib/glyphs/lucide_icon.rb
|
|
78
80
|
- lib/glyphs/phosphor_icon.rb
|
|
81
|
+
- lib/glyphs/prune_report.rb
|
|
82
|
+
- lib/glyphs/prune_runner.rb
|
|
79
83
|
- lib/glyphs/radix_icon.rb
|
|
84
|
+
- lib/glyphs/railtie.rb
|
|
80
85
|
- lib/glyphs/rubocop.rb
|
|
81
86
|
- lib/glyphs/sidekick_icon.rb
|
|
87
|
+
- lib/glyphs/source_scanner.rb
|
|
82
88
|
- lib/glyphs/tabler_icon.rb
|
|
83
89
|
- lib/glyphs/version.rb
|
|
84
90
|
- lib/glyphs/weather_icon.rb
|
|
@@ -86,6 +92,7 @@ files:
|
|
|
86
92
|
- lib/rubocop/cop/glyphs/legacy_icon_helper.rb
|
|
87
93
|
- lib/rubocop/cop/glyphs/library_call_helpers.rb
|
|
88
94
|
- lib/rubocop/cop/glyphs/prefer_library_component.rb
|
|
95
|
+
- lib/tasks/glyphs.rake
|
|
89
96
|
homepage: https://github.com/mhenrixon/glyphs
|
|
90
97
|
licenses:
|
|
91
98
|
- MIT
|
|
@@ -102,7 +109,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
102
109
|
requirements:
|
|
103
110
|
- - ">="
|
|
104
111
|
- !ruby/object:Gem::Version
|
|
105
|
-
version: '3.
|
|
112
|
+
version: '3.4'
|
|
106
113
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
114
|
requirements:
|
|
108
115
|
- - ">="
|