rails-css_unused 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/lib/rails/css_unused/configuration.rb +45 -0
- data/lib/rails/css_unused/railtie.rb +24 -0
- data/lib/rails/css_unused/report.rb +50 -0
- data/lib/rails/css_unused/stylesheet_scanner.rb +82 -0
- data/lib/rails/css_unused/tasks.rake +12 -0
- data/lib/rails/css_unused/version.rb +7 -0
- data/lib/rails/css_unused/view_scanner.rb +117 -0
- data/lib/rails/css_unused.rb +36 -0
- data/lib/rails-css_unused.rb +3 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0d73f075e43405f546976ed7bac31bda9ccc6ac96bfa16f0dc0fa6dbc78a3242
|
|
4
|
+
data.tar.gz: de225a6ff929dfbb1f1075cc142beb93b7e25715c76905efc3daacb00d531662
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0a0dd43d7354169aa25f7a3274e58ab05d5d4e2a36cb6546994fbf22512404c2d5d8bb6827116358a71fb62911427b9e2692f6a6753fd5040e848043666e30ab
|
|
7
|
+
data.tar.gz: af1263e05f8e44c228334495438c493e8c079c2f03e21118435a6d10fe5971cf07d90e7bd610d5654a6731d61b35923c1d512443bc06fc9c608a9feae634424d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-06-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `css_unused:report` and `css_unused:ghosts` Rake tasks (via Railtie)
|
|
13
|
+
- View scanner for `app/views` and `app/components` (ERB, HAML, `class:` helpers)
|
|
14
|
+
- Stylesheet scanner for CSS/SCSS/Sass under assets and `app/javascript`
|
|
15
|
+
- `Rails::CssUnused.report` and `Rails::CssUnused.ghost_classes` APIs
|
|
16
|
+
- `Rails::CssUnused.configure` for paths and ignored classes
|
|
17
|
+
|
|
18
|
+
### Known limitations
|
|
19
|
+
|
|
20
|
+
- Dynamic classes in ERB may not be detected
|
|
21
|
+
- Tailwind / build-time utilities may report false positives
|
|
22
|
+
- Classes added only via JavaScript are not detected
|
|
23
|
+
|
|
24
|
+
[0.1.0]: https://github.com/sghani001/rails-css_unused/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# rails-css_unused
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/rails-css_unused)
|
|
4
|
+
[](https://github.com/sghani001/rails-css_unused)
|
|
5
|
+
|
|
6
|
+
Find **ghost CSS classes** in your Rails app: selectors that exist in your stylesheets but never show up in views or ViewComponents.
|
|
7
|
+
|
|
8
|
+
Pure static analysis — no browser, no headless Chrome. Built for the usual Rails paths (`app/views`, `app/components`, `app/assets/stylesheets`).
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Ruby **>= 3.1**
|
|
13
|
+
- Rails **>= 7.0** (via `railties`)
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
gem "rails-css_unused", group: :development
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bundle install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or install the gem directly (after it is published on RubyGems):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
gem install rails-css_unused
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
During development of this gem itself, use a path install:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
gem "rails-css_unused", path: "../rails-css_unused", group: :development
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bin/rails css_unused:report
|
|
42
|
+
# or
|
|
43
|
+
bin/rails css_unused:ghosts
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Example output:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
rails-css_unused — Ghost Class Report
|
|
50
|
+
========================================
|
|
51
|
+
Project root: /path/to/myapp
|
|
52
|
+
Classes in stylesheets: 142
|
|
53
|
+
Classes referenced in views: 118
|
|
54
|
+
Ghost classes (in CSS, not in views): 24
|
|
55
|
+
|
|
56
|
+
Ghost classes:
|
|
57
|
+
legacy-banner
|
|
58
|
+
orphan-widget
|
|
59
|
+
...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What it scans
|
|
63
|
+
|
|
64
|
+
| Source | Paths (default) |
|
|
65
|
+
|--------|-----------------|
|
|
66
|
+
| Views | `app/views` — `.html.erb`, `.html.haml`, `.haml`, `.erb`, `.slim` |
|
|
67
|
+
| Components | `app/components` — same extensions |
|
|
68
|
+
| Styles | `app/assets/stylesheets`, `app/assets/builds` — `.css`, `.scss`, `.sass` |
|
|
69
|
+
| JS CSS | `app/javascript` — same stylesheet extensions |
|
|
70
|
+
|
|
71
|
+
### Class detection in templates
|
|
72
|
+
|
|
73
|
+
- `class="foo bar"`
|
|
74
|
+
- `class: "foo"`, `class: 'foo'`
|
|
75
|
+
- `class: %w[foo bar]`, `class: ["foo", "bar"]`
|
|
76
|
+
- `tag.div ..., class: "foo"`
|
|
77
|
+
- Basic HAML `.class-name` segments
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# config/initializers/rails_css_unused.rb
|
|
83
|
+
Rails::CssUnused.configure do |config|
|
|
84
|
+
config.ignore_classes += %w[active hidden]
|
|
85
|
+
config.stylesheet_paths << "vendor/assets/stylesheets"
|
|
86
|
+
config.view_paths << "app/views/admin"
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Optional `config/application.rb` hook:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
config.rails_css_unused = ActiveSupport::OrderedOptions.new
|
|
94
|
+
config.rails_css_unused.ignore_classes = %w[sr-only]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Limitations
|
|
98
|
+
|
|
99
|
+
Static analysis cannot see everything:
|
|
100
|
+
|
|
101
|
+
- **Dynamic classes** — `class="<%= dynamic %>"` may be missed or only partially detected.
|
|
102
|
+
- **Tailwind / utility frameworks** — utilities are often generated at build time; many “ghost” hits are false positives unless you scan compiled `app/assets/builds` and tune `ignore_classes`.
|
|
103
|
+
- **JavaScript-added classes** — Stimulus, React, or `element.classList.add` are not scanned.
|
|
104
|
+
- **@extend / mixins** — SCSS may define classes only used inside other rules; review before deleting.
|
|
105
|
+
|
|
106
|
+
Treat the report as a **triage list**, not an automatic delete command.
|
|
107
|
+
|
|
108
|
+
## Programmatic API
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
Rails::CssUnused.ghost_classes
|
|
112
|
+
# => ["orphan-widget", "legacy-banner", ...]
|
|
113
|
+
|
|
114
|
+
Rails::CssUnused.report
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bundle install
|
|
121
|
+
ruby -Ilib -S rspec
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Contributing
|
|
125
|
+
|
|
126
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/sghani001/rails-css_unused).
|
|
127
|
+
|
|
128
|
+
Maintainers: see [PUBLISHING.md](PUBLISHING.md) for the release checklist and RubyGems publish steps.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module CssUnused
|
|
5
|
+
class Configuration
|
|
6
|
+
VIEW_EXTENSIONS = %w[.html.erb .html.haml .haml .erb .slim .jbuilder].freeze
|
|
7
|
+
COMPONENT_EXTENSIONS = %w[.html.erb .html.haml .haml .erb .slim].freeze
|
|
8
|
+
CSS_EXTENSIONS = %w[.css .scss .sass].freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor :view_paths,
|
|
11
|
+
:component_paths,
|
|
12
|
+
:stylesheet_paths,
|
|
13
|
+
:javascript_paths,
|
|
14
|
+
:ignore_classes,
|
|
15
|
+
:ignore_selectors_matching
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@view_paths = ["app/views"]
|
|
19
|
+
@component_paths = ["app/components"]
|
|
20
|
+
@stylesheet_paths = ["app/assets/stylesheets", "app/assets/builds"]
|
|
21
|
+
@javascript_paths = ["app/javascript"]
|
|
22
|
+
@ignore_classes = %w[
|
|
23
|
+
clearfix
|
|
24
|
+
sr-only
|
|
25
|
+
visually-hidden
|
|
26
|
+
]
|
|
27
|
+
@ignore_selectors_matching = []
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def configuration
|
|
33
|
+
@configuration ||= Configuration.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def configure
|
|
37
|
+
yield(configuration)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reset_configuration!
|
|
41
|
+
@configuration = nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module CssUnused
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
rake_tasks do
|
|
7
|
+
load File.expand_path("tasks.rake", __dir__)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
initializer "rails_css_unused.configuration" do
|
|
11
|
+
if Rails.application.config.respond_to?(:rails_css_unused)
|
|
12
|
+
cfg = Rails.application.config.rails_css_unused
|
|
13
|
+
CssUnused.configure do |c|
|
|
14
|
+
c.view_paths = cfg.view_paths if cfg.respond_to?(:view_paths) && cfg.view_paths
|
|
15
|
+
c.component_paths = cfg.component_paths if cfg.respond_to?(:component_paths) && cfg.component_paths
|
|
16
|
+
c.stylesheet_paths = cfg.stylesheet_paths if cfg.respond_to?(:stylesheet_paths) && cfg.stylesheet_paths
|
|
17
|
+
c.javascript_paths = cfg.javascript_paths if cfg.respond_to?(:javascript_paths) && cfg.javascript_paths
|
|
18
|
+
c.ignore_classes = cfg.ignore_classes if cfg.respond_to?(:ignore_classes) && cfg.ignore_classes
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module CssUnused
|
|
5
|
+
class Report
|
|
6
|
+
Ghost = Struct.new(:class_name, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
def initialize(root:, output: $stdout, config: CssUnused.configuration)
|
|
9
|
+
@root = Pathname(root)
|
|
10
|
+
@output = output
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ghost_classes
|
|
15
|
+
used = ViewScanner.new(root: @root, config: @config).used_classes
|
|
16
|
+
defined = StylesheetScanner.new(root: @root, config: @config).defined_classes
|
|
17
|
+
(defined - used).sort.map { |name| Ghost.new(class_name: name) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def print_summary
|
|
21
|
+
ghosts = ghost_classes
|
|
22
|
+
used_count = ViewScanner.new(root: @root, config: @config).used_classes.size
|
|
23
|
+
defined_count = StylesheetScanner.new(root: @root, config: @config).defined_classes.size
|
|
24
|
+
|
|
25
|
+
@output.puts
|
|
26
|
+
@output.puts "rails-css_unused — Ghost Class Report"
|
|
27
|
+
@output.puts "=" * 40
|
|
28
|
+
@output.puts "Project root: #{@root}"
|
|
29
|
+
@output.puts "Classes in stylesheets: #{defined_count}"
|
|
30
|
+
@output.puts "Classes referenced in views: #{used_count}"
|
|
31
|
+
@output.puts "Ghost classes (in CSS, not in views): #{ghosts.size}"
|
|
32
|
+
@output.puts
|
|
33
|
+
|
|
34
|
+
if ghosts.empty?
|
|
35
|
+
@output.puts "No ghost classes found. Nice and tidy!"
|
|
36
|
+
else
|
|
37
|
+
@output.puts "Ghost classes:"
|
|
38
|
+
ghosts.each { |g| @output.puts " #{g.class_name}" }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@output.puts
|
|
42
|
+
@output.puts "Note: Dynamic class names and Tailwind utilities compiled at build time"
|
|
43
|
+
@output.puts "may produce false positives. See README for configuration."
|
|
44
|
+
@output.puts
|
|
45
|
+
|
|
46
|
+
ghosts
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module CssUnused
|
|
5
|
+
# Extracts class selectors from CSS/SCSS/Sass files (static parse, no full AST).
|
|
6
|
+
class StylesheetScanner
|
|
7
|
+
# .class-name, .foo.bar, div.class — capture class tokens after dots
|
|
8
|
+
CLASS_SELECTOR_PATTERN = /\.([a-zA-Z_][\w-]*(?:--[\w-]+)?)/
|
|
9
|
+
|
|
10
|
+
# Strip comments before matching
|
|
11
|
+
BLOCK_COMMENT = %r{/\*.*?\*/}m
|
|
12
|
+
LINE_COMMENT = %r{//[^\n]*}
|
|
13
|
+
|
|
14
|
+
def initialize(root:, config: CssUnused.configuration)
|
|
15
|
+
@root = Pathname(root)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def defined_classes
|
|
20
|
+
classes = Set.new
|
|
21
|
+
each_stylesheet_file { |content| classes.merge(extract_from(strip_comments(content))) }
|
|
22
|
+
classes.subtract(normalized_ignore_list)
|
|
23
|
+
classes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def each_stylesheet_file
|
|
29
|
+
search_roots = @config.stylesheet_paths + @config.javascript_paths
|
|
30
|
+
extensions = Configuration::CSS_EXTENSIONS
|
|
31
|
+
|
|
32
|
+
search_roots.each do |relative|
|
|
33
|
+
dir = @root.join(relative)
|
|
34
|
+
next unless dir.directory?
|
|
35
|
+
|
|
36
|
+
Dir.glob(dir.join("**", "*")).each do |file|
|
|
37
|
+
path = Pathname(file)
|
|
38
|
+
next unless path.file?
|
|
39
|
+
next unless extensions.include?(path.extname)
|
|
40
|
+
|
|
41
|
+
yield read_file(path)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def read_file(path)
|
|
47
|
+
path.read(encoding: Encoding::UTF_8)
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
path.read
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def strip_comments(css)
|
|
53
|
+
css.gsub(BLOCK_COMMENT, "").gsub(LINE_COMMENT, "")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extract_from(css)
|
|
57
|
+
found = Set.new
|
|
58
|
+
css.scan(CLASS_SELECTOR_PATTERN) do |match|
|
|
59
|
+
class_name = match.is_a?(Array) ? match[0] : match
|
|
60
|
+
next if skip_selector?(class_name, css)
|
|
61
|
+
|
|
62
|
+
found << class_name
|
|
63
|
+
end
|
|
64
|
+
found
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def skip_selector?(class_name, _css)
|
|
68
|
+
return true if pseudo_or_utility_noise?(class_name)
|
|
69
|
+
|
|
70
|
+
@config.ignore_selectors_matching.any? { |pattern| class_name.match?(pattern) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pseudo_or_utility_noise?(name)
|
|
74
|
+
%w[import media charset namespace].include?(name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalized_ignore_list
|
|
78
|
+
@config.ignore_classes.map(&:to_s)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :css_unused do
|
|
4
|
+
desc "List CSS classes defined in stylesheets but not referenced in views/components"
|
|
5
|
+
task report: :environment do
|
|
6
|
+
require "rails/css_unused"
|
|
7
|
+
Rails::CssUnused.report
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Same as report (alias)"
|
|
11
|
+
task ghosts: :report
|
|
12
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module CssUnused
|
|
5
|
+
# Extracts CSS class names referenced in Rails views and components via regex.
|
|
6
|
+
# Dynamic classes (ERB interpolation) are only partially detected — see README.
|
|
7
|
+
class ViewScanner
|
|
8
|
+
# class="foo bar", class='foo', class: "foo", class: 'foo', class: %w[foo bar]
|
|
9
|
+
CLASS_ATTRIBUTE_PATTERN = /
|
|
10
|
+
class\s*=\s*["']([^"']+)["']
|
|
11
|
+
|
|
|
12
|
+
class:\s*["']([^"']+)["']
|
|
13
|
+
|
|
|
14
|
+
class:\s*%w\[\s*([^\]]+)\]
|
|
15
|
+
|
|
|
16
|
+
class:\s*\[\s*([^\]]+)\]
|
|
17
|
+
/ix
|
|
18
|
+
|
|
19
|
+
# HAML: .foo.bar or %div.foo
|
|
20
|
+
HAML_CLASS_PATTERN = /\.([a-zA-Z_][\w-]*)/
|
|
21
|
+
|
|
22
|
+
# Tailwind-style @apply or data-class rarely; common helper: tag.div class: "x"
|
|
23
|
+
TAG_HELPER_CLASS_PATTERN = /(?:^|\s)class:\s*["']([^"']+)["']/m
|
|
24
|
+
|
|
25
|
+
def initialize(root:, config: CssUnused.configuration)
|
|
26
|
+
@root = Pathname(root)
|
|
27
|
+
@config = config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def used_classes
|
|
31
|
+
classes = Set.new
|
|
32
|
+
each_view_file { |path, content| classes.merge(extract_from(content, path)) }
|
|
33
|
+
classes.subtract(normalized_ignore_list)
|
|
34
|
+
classes
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def each_view_file
|
|
40
|
+
paths = @config.view_paths + @config.component_paths
|
|
41
|
+
extensions = (Configuration::VIEW_EXTENSIONS + Configuration::COMPONENT_EXTENSIONS).uniq
|
|
42
|
+
|
|
43
|
+
paths.each do |relative|
|
|
44
|
+
dir = @root.join(relative)
|
|
45
|
+
next unless dir.directory?
|
|
46
|
+
|
|
47
|
+
Dir.glob(dir.join("**", "*")).each do |file|
|
|
48
|
+
path = Pathname(file)
|
|
49
|
+
next unless path.file?
|
|
50
|
+
next unless extensions.include?(path.extname) || compound_extension?(path)
|
|
51
|
+
|
|
52
|
+
yield path, path.read(encoding: Encoding::UTF_8)
|
|
53
|
+
rescue ArgumentError
|
|
54
|
+
yield path, path.read
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compound_extension?(path)
|
|
60
|
+
name = path.basename.to_s
|
|
61
|
+
Configuration::VIEW_EXTENSIONS.any? { |ext| name.end_with?(ext) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extract_from(content, path)
|
|
65
|
+
found = Set.new
|
|
66
|
+
content.scan(CLASS_ATTRIBUTE_PATTERN).flatten.compact.each do |chunk|
|
|
67
|
+
found.merge(tokenize_class_value(chunk))
|
|
68
|
+
end
|
|
69
|
+
content.scan(TAG_HELPER_CLASS_PATTERN) { |m| found.merge(tokenize_class_value(m[0])) }
|
|
70
|
+
if haml_file?(path)
|
|
71
|
+
content.scan(HAML_CLASS_PATTERN) { |m| found << m[0] unless haml_false_positive?(m[0]) }
|
|
72
|
+
end
|
|
73
|
+
found.merge(extract_erb_interpolated_classes(content))
|
|
74
|
+
found
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def haml_file?(path)
|
|
78
|
+
path.extname == ".haml" || path.to_s.end_with?(".html.haml")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Skip decimal numbers like .5 in HAML (rare in class position but possible in CSS snippets)
|
|
82
|
+
def haml_false_positive?(token)
|
|
83
|
+
token.match?(/\A\d/)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tokenize_class_value(raw)
|
|
87
|
+
raw
|
|
88
|
+
.gsub(/['"]/, " ")
|
|
89
|
+
.split(/[\s,]+/)
|
|
90
|
+
.map { |t| t.strip.sub(/\A\./, "") }
|
|
91
|
+
.reject(&:empty?)
|
|
92
|
+
.reject { |t| t.include?("<%") || t.include?('#{') }
|
|
93
|
+
.select { |t| valid_class_token?(t) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def valid_class_token?(token)
|
|
97
|
+
token.match?(/\A[a-zA-Z_][\w-]*\z/) || token.match?(/\A[a-zA-Z_][\w-]*--[\w-]+\z/)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Picks up literal segments inside ERB-interpolated class attributes when present.
|
|
101
|
+
def extract_erb_interpolated_classes(content)
|
|
102
|
+
found = Set.new
|
|
103
|
+
content.scan(/class\s*=\s*["'][^"']*<%=[^%]+%>[^"']*["']/m) do
|
|
104
|
+
literal = Regexp.last_match(0)
|
|
105
|
+
literal.gsub(/<%.*?%>/m, " ").scan(/["']([^"']+)["']/) do |part|
|
|
106
|
+
found.merge(tokenize_class_value(part[0]))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
found
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalized_ignore_list
|
|
113
|
+
@config.ignore_classes.map(&:to_s)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
require_relative "css_unused/version"
|
|
7
|
+
require_relative "css_unused/configuration"
|
|
8
|
+
require_relative "css_unused/view_scanner"
|
|
9
|
+
require_relative "css_unused/stylesheet_scanner"
|
|
10
|
+
require_relative "css_unused/report"
|
|
11
|
+
|
|
12
|
+
module Rails
|
|
13
|
+
module CssUnused
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def report(root: default_root, output: $stdout)
|
|
18
|
+
Report.new(root: root, output: output).print_summary
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ghost_classes(root: default_root)
|
|
22
|
+
Report.new(root: root).ghost_classes.map(&:class_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_root
|
|
26
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
|
27
|
+
Rails.root
|
|
28
|
+
else
|
|
29
|
+
Pathname.new(Dir.pwd)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
require_relative "css_unused/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-css_unused
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- sghani001
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: railties
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: rake
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
type: :development
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: rspec
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.12'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.12'
|
|
61
|
+
description: |
|
|
62
|
+
A lightweight Rake task that regex-scans views and components for CSS class
|
|
63
|
+
references, compares them to selectors in your stylesheets, and reports
|
|
64
|
+
ghost classes defined in CSS but never used in templates.
|
|
65
|
+
email:
|
|
66
|
+
- sghani001@users.noreply.github.com
|
|
67
|
+
executables: []
|
|
68
|
+
extensions: []
|
|
69
|
+
extra_rdoc_files: []
|
|
70
|
+
files:
|
|
71
|
+
- CHANGELOG.md
|
|
72
|
+
- LICENSE.txt
|
|
73
|
+
- README.md
|
|
74
|
+
- lib/rails-css_unused.rb
|
|
75
|
+
- lib/rails/css_unused.rb
|
|
76
|
+
- lib/rails/css_unused/configuration.rb
|
|
77
|
+
- lib/rails/css_unused/railtie.rb
|
|
78
|
+
- lib/rails/css_unused/report.rb
|
|
79
|
+
- lib/rails/css_unused/stylesheet_scanner.rb
|
|
80
|
+
- lib/rails/css_unused/tasks.rake
|
|
81
|
+
- lib/rails/css_unused/version.rb
|
|
82
|
+
- lib/rails/css_unused/view_scanner.rb
|
|
83
|
+
homepage: https://github.com/sghani001/rails-css_unused
|
|
84
|
+
licenses:
|
|
85
|
+
- MIT
|
|
86
|
+
metadata:
|
|
87
|
+
allowed_push_host: https://rubygems.org
|
|
88
|
+
homepage_uri: https://github.com/sghani001/rails-css_unused
|
|
89
|
+
source_code_uri: https://github.com/sghani001/rails-css_unused
|
|
90
|
+
changelog_uri: https://github.com/sghani001/rails-css_unused/blob/main/CHANGELOG.md
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
post_install_message:
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: 3.1.0
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 3.5.3
|
|
108
|
+
signing_key:
|
|
109
|
+
specification_version: 4
|
|
110
|
+
summary: Find unused CSS classes in Rails apps via static analysis
|
|
111
|
+
test_files: []
|