react-manifest-rails 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 +22 -0
- data/README.md +244 -0
- data/lib/react_manifest/application_analyzer.rb +150 -0
- data/lib/react_manifest/application_migrator.rb +101 -0
- data/lib/react_manifest/configuration.rb +67 -0
- data/lib/react_manifest/dependency_map.rb +52 -0
- data/lib/react_manifest/generator.rb +206 -0
- data/lib/react_manifest/railtie.rb +46 -0
- data/lib/react_manifest/reporter.rb +96 -0
- data/lib/react_manifest/scanner.rb +192 -0
- data/lib/react_manifest/tree_classifier.rb +69 -0
- data/lib/react_manifest/version.rb +3 -0
- data/lib/react_manifest/view_helpers.rb +27 -0
- data/lib/react_manifest/watcher.rb +74 -0
- data/lib/react_manifest.rb +83 -0
- data/lib/react_manifest_rails.rb +2 -0
- data/tasks/react_manifest.rake +89 -0
- metadata +166 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "set"
|
|
3
|
+
require "time"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module ReactManifest
|
|
7
|
+
# Generates all ux_*.js Sprockets manifest files.
|
|
8
|
+
#
|
|
9
|
+
# Generates:
|
|
10
|
+
# ux_shared.js — requires all files from shared dirs (components/, hooks/, lib/, etc.)
|
|
11
|
+
# ux_<ctrl>.js — one per controller subdir, requires ux_shared + controller files
|
|
12
|
+
#
|
|
13
|
+
# All generated files carry the AUTO-GENERATED header and are idempotent
|
|
14
|
+
# (skips write if content unchanged). Writes are atomic (temp-file + rename)
|
|
15
|
+
# to avoid partial reads from concurrent processes.
|
|
16
|
+
#
|
|
17
|
+
# Never touches application.js, application_dev.js, or files in exclude_paths.
|
|
18
|
+
class Generator
|
|
19
|
+
HEADER = <<~JS
|
|
20
|
+
// AUTO-GENERATED — DO NOT EDIT
|
|
21
|
+
// react-manifest-rails %<version>s | %<timestamp>s
|
|
22
|
+
// Run `rails react_manifest:generate` to regenerate.
|
|
23
|
+
JS
|
|
24
|
+
|
|
25
|
+
def initialize(config = ReactManifest.configuration)
|
|
26
|
+
@config = config
|
|
27
|
+
@classifier = TreeClassifier.new(config)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Run full generation. Returns array of {path:, status:} hashes.
|
|
31
|
+
def run!
|
|
32
|
+
results = []
|
|
33
|
+
classification = @classifier.classify
|
|
34
|
+
|
|
35
|
+
# 1. Generate ux_shared.js
|
|
36
|
+
results << generate_shared(classification.shared_dirs)
|
|
37
|
+
|
|
38
|
+
# 2. Generate one ux_<controller>.js per controller dir
|
|
39
|
+
classification.controller_dirs.each do |ctrl|
|
|
40
|
+
results << generate_controller(ctrl)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
print_summary(results) if @config.verbose?
|
|
44
|
+
results
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------ shared
|
|
50
|
+
|
|
51
|
+
def generate_shared(shared_dirs)
|
|
52
|
+
lines = header_lines
|
|
53
|
+
any_files = false
|
|
54
|
+
|
|
55
|
+
shared_dirs.each do |shared_dir|
|
|
56
|
+
files = js_files_in(shared_dir[:path])
|
|
57
|
+
next if files.empty?
|
|
58
|
+
|
|
59
|
+
any_files = true
|
|
60
|
+
files.each { |f| lines << "//= require #{relative_require_path(f)}" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless any_files
|
|
64
|
+
lines << "// (no shared files found)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
bundle_name = @config.shared_bundle
|
|
68
|
+
write_manifest("#{bundle_name}.js", lines.join("\n") + "\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------- controller
|
|
72
|
+
|
|
73
|
+
def generate_controller(ctrl)
|
|
74
|
+
lines = header_lines
|
|
75
|
+
lines << "//= require #{@config.shared_bundle}"
|
|
76
|
+
lines << ""
|
|
77
|
+
|
|
78
|
+
files = js_files_in(ctrl[:path])
|
|
79
|
+
if files.empty?
|
|
80
|
+
lines << "// (no JSX files found in #{ctrl[:name]}/)"
|
|
81
|
+
else
|
|
82
|
+
files.each { |f| lines << "//= require #{relative_require_path(f)}" }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
write_manifest("#{ctrl[:bundle_name]}.js", lines.join("\n") + "\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------- write
|
|
89
|
+
|
|
90
|
+
def write_manifest(filename, content)
|
|
91
|
+
dest = File.join(@config.abs_output_dir, filename)
|
|
92
|
+
|
|
93
|
+
# Safety: never touch files not bearing our AUTO-GENERATED header
|
|
94
|
+
# (unless they don't exist yet)
|
|
95
|
+
if File.exist?(dest) && !auto_generated?(dest)
|
|
96
|
+
return { path: dest, status: :skipped_pinned }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
new_digest = Digest::SHA256.hexdigest(content)
|
|
100
|
+
|
|
101
|
+
if File.exist?(dest)
|
|
102
|
+
existing_digest = Digest::SHA256.hexdigest(File.read(dest, encoding: "utf-8"))
|
|
103
|
+
if existing_digest == new_digest
|
|
104
|
+
return { path: dest, status: :unchanged }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if @config.dry_run?
|
|
109
|
+
$stdout.puts "[ReactManifest] DRY-RUN: would write #{dest}"
|
|
110
|
+
print_diff(dest, content)
|
|
111
|
+
return { path: dest, status: :dry_run }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
115
|
+
|
|
116
|
+
# Atomic write: write to a temp file in the same directory then rename,
|
|
117
|
+
# so concurrent readers never see a partially-written manifest.
|
|
118
|
+
tmp = "#{dest}.tmp.#{Process.pid}"
|
|
119
|
+
begin
|
|
120
|
+
File.write(tmp, content, encoding: "utf-8")
|
|
121
|
+
File.rename(tmp, dest)
|
|
122
|
+
rescue => e
|
|
123
|
+
File.unlink(tmp) if File.exist?(tmp)
|
|
124
|
+
raise e
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
{ path: dest, status: :written }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ----------------------------------------------------------- helpers
|
|
131
|
+
|
|
132
|
+
def header_lines
|
|
133
|
+
[
|
|
134
|
+
HEADER % { version: ReactManifest::VERSION, timestamp: Time.now.utc.iso8601 },
|
|
135
|
+
""
|
|
136
|
+
].flatten
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def js_files_in(dir)
|
|
140
|
+
return [] unless Dir.exist?(dir)
|
|
141
|
+
|
|
142
|
+
files = Dir.glob(File.join(dir, "**", "*.{js,jsx}"))
|
|
143
|
+
.reject { |f| File.directory?(f) }
|
|
144
|
+
.reject { |f| auto_generated?(f) }
|
|
145
|
+
.reject { |f| excluded_path?(f) }
|
|
146
|
+
.sort
|
|
147
|
+
|
|
148
|
+
# Deduplicate by logical require path: if both foo.js and foo.jsx exist,
|
|
149
|
+
# keep foo.js (sorted first) to avoid duplicate //= require directives
|
|
150
|
+
# that would cause a Sprockets error.
|
|
151
|
+
seen = Set.new
|
|
152
|
+
files.each_with_object([]) do |f, uniq|
|
|
153
|
+
logical = relative_require_path(f)
|
|
154
|
+
next if seen.include?(logical)
|
|
155
|
+
seen << logical
|
|
156
|
+
uniq << f
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Returns true if the file path contains a component matching any exclude_path.
|
|
161
|
+
# exclude_paths are matched against individual path segments, so "vendor" matches
|
|
162
|
+
# ux/vendor/foo.js but not ux/vendor_custom/foo.js.
|
|
163
|
+
def excluded_path?(abs_path)
|
|
164
|
+
parts = abs_path.split(File::SEPARATOR)
|
|
165
|
+
@config.exclude_paths.any? { |ep| parts.include?(ep) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def relative_require_path(abs_path)
|
|
169
|
+
# Build relative to output_dir (configurable) rather than a hardcoded path.
|
|
170
|
+
base = @config.abs_output_dir + File::SEPARATOR
|
|
171
|
+
rel = abs_path.sub(base, "")
|
|
172
|
+
# Strip Sprockets-understood extensions: .js.jsx → "", .jsx → "", .js → ""
|
|
173
|
+
rel.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def auto_generated?(path)
|
|
177
|
+
return false unless File.exist?(path)
|
|
178
|
+
# Read up to first 2 lines — handles empty files (first.to_s returns "")
|
|
179
|
+
# without incorrectly treating them as user-pinned.
|
|
180
|
+
first_two = File.foreach(path).first(2).join
|
|
181
|
+
first_two.include?("AUTO-GENERATED")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def print_diff(dest, new_content)
|
|
185
|
+
if File.exist?(dest)
|
|
186
|
+
old_lines = File.readlines(dest, encoding: "utf-8")
|
|
187
|
+
new_lines = new_content.lines
|
|
188
|
+
|
|
189
|
+
removed = old_lines - new_lines
|
|
190
|
+
added = new_lines - old_lines
|
|
191
|
+
|
|
192
|
+
removed.each { |l| $stdout.puts " - #{l.chomp}" }
|
|
193
|
+
added.each { |l| $stdout.puts " + #{l.chomp}" }
|
|
194
|
+
else
|
|
195
|
+
new_content.each_line { |l| $stdout.puts " + #{l.chomp}" }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def print_summary(results)
|
|
200
|
+
counts = results.group_by { |r| r[:status] }.transform_values(&:count)
|
|
201
|
+
$stdout.puts "[ReactManifest] Generated: #{counts[:written] || 0} written, " \
|
|
202
|
+
"#{counts[:unchanged] || 0} unchanged, " \
|
|
203
|
+
"#{counts[:skipped_pinned] || 0} skipped (not auto-generated)"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module ReactManifest
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
# Expose config as Rails.application.config.react_manifest
|
|
6
|
+
config.react_manifest = ReactManifest.configuration
|
|
7
|
+
|
|
8
|
+
# ----------------------------------------------------------------
|
|
9
|
+
# Start the file watcher in development
|
|
10
|
+
# ----------------------------------------------------------------
|
|
11
|
+
initializer "react_manifest.start_watcher" do
|
|
12
|
+
if Rails.env.development? && !ReactManifest::Watcher.running?
|
|
13
|
+
begin
|
|
14
|
+
ReactManifest::Watcher.start(ReactManifest.configuration)
|
|
15
|
+
rescue => e
|
|
16
|
+
Rails.logger.warn "[ReactManifest] Could not start file watcher: #{e.message}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ----------------------------------------------------------------
|
|
22
|
+
# Include react_bundle_tag in all ActionView templates
|
|
23
|
+
# ----------------------------------------------------------------
|
|
24
|
+
initializer "react_manifest.view_helpers" do
|
|
25
|
+
ActiveSupport.on_load(:action_view) do
|
|
26
|
+
include ReactManifest::ViewHelpers
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ----------------------------------------------------------------
|
|
31
|
+
# Hook manifest generation into assets:precompile
|
|
32
|
+
# (safety net for CI/production — dev uses the watcher)
|
|
33
|
+
#
|
|
34
|
+
# Using prepend_actions so generation runs as a block before
|
|
35
|
+
# Sprockets begins compiling, rather than as a Rake prerequisite
|
|
36
|
+
# (which is subject to parallel task ordering under rake -j).
|
|
37
|
+
# ----------------------------------------------------------------
|
|
38
|
+
rake_tasks do
|
|
39
|
+
load File.expand_path("../../../tasks/react_manifest.rake", __FILE__)
|
|
40
|
+
|
|
41
|
+
if Rake::Task.task_defined?("assets:precompile")
|
|
42
|
+
Rake::Task["assets:precompile"].enhance(["react_manifest:generate"])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "zlib"
|
|
3
|
+
|
|
4
|
+
module ReactManifest
|
|
5
|
+
# Reports per-bundle sizes by reading the Sprockets compiled manifest.
|
|
6
|
+
#
|
|
7
|
+
# Run after `rails assets:precompile` to see:
|
|
8
|
+
# Bundle Raw (KB) Gzip (KB)
|
|
9
|
+
# ux_shared.js 420 130
|
|
10
|
+
# ux_notifications.js 95 31 ✓
|
|
11
|
+
# ux_reports.js 610 190 ⚠
|
|
12
|
+
class Reporter
|
|
13
|
+
def initialize(config = ReactManifest.configuration)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def report
|
|
18
|
+
manifest = load_sprockets_manifest
|
|
19
|
+
bundles = collect_bundles(manifest)
|
|
20
|
+
|
|
21
|
+
if bundles.empty?
|
|
22
|
+
puts "[ReactManifest] No ux_*.js bundles found in compiled assets."
|
|
23
|
+
puts " Run `rails assets:precompile` first."
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
print_table(bundles)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def load_sprockets_manifest
|
|
33
|
+
# Use Rails.public_path to respect non-default public directory config.
|
|
34
|
+
public_assets = Rails.public_path.join("assets")
|
|
35
|
+
manifest_file = Dir.glob(File.join(public_assets, ".sprockets-manifest-*.json")).first ||
|
|
36
|
+
Dir.glob(File.join(public_assets, "manifest-*.json")).first
|
|
37
|
+
|
|
38
|
+
unless manifest_file && File.exist?(manifest_file)
|
|
39
|
+
puts "[ReactManifest] Sprockets manifest not found under #{public_assets}."
|
|
40
|
+
puts " Run `rails assets:precompile` first."
|
|
41
|
+
return {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
JSON.parse(File.read(manifest_file, encoding: "utf-8"))["assets"] || {}
|
|
45
|
+
rescue JSON::ParserError => e
|
|
46
|
+
puts "[ReactManifest] Could not parse Sprockets manifest: #{e.message}"
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def collect_bundles(manifest)
|
|
51
|
+
public_assets = Rails.public_path.join("assets").to_s
|
|
52
|
+
bundles = []
|
|
53
|
+
|
|
54
|
+
manifest.each do |logical_path, fingerprinted|
|
|
55
|
+
next unless logical_path.match?(/\Aux_.*\.js\z/)
|
|
56
|
+
|
|
57
|
+
abs_path = File.join(public_assets, fingerprinted)
|
|
58
|
+
gz_path = "#{abs_path}.gz"
|
|
59
|
+
|
|
60
|
+
next unless File.exist?(abs_path)
|
|
61
|
+
|
|
62
|
+
raw_kb = (File.size(abs_path) / 1024.0).round(1)
|
|
63
|
+
gzip_kb = File.exist?(gz_path) ? (File.size(gz_path) / 1024.0).round(1) : nil
|
|
64
|
+
|
|
65
|
+
bundles << {
|
|
66
|
+
name: logical_path,
|
|
67
|
+
raw_kb: raw_kb,
|
|
68
|
+
gzip_kb: gzip_kb
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
bundles.sort_by { |b| b[:name] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_table(bundles)
|
|
76
|
+
gzip_available = bundles.any? { |b| b[:gzip_kb] }
|
|
77
|
+
|
|
78
|
+
puts "\n#{"Bundle".ljust(35)} #{"Raw (KB)".rjust(10)}#{gzip_available ? " #{"Gzip (KB)".rjust(10)}" : ""}"
|
|
79
|
+
puts "-" * (gzip_available ? 62 : 48)
|
|
80
|
+
|
|
81
|
+
bundles.each do |b|
|
|
82
|
+
over_threshold = @config.size_threshold_kb > 0 && b[:raw_kb] > @config.size_threshold_kb
|
|
83
|
+
flag = over_threshold ? " ⚠ exceeds #{@config.size_threshold_kb}KB threshold" : ""
|
|
84
|
+
gzip_col = gzip_available ? " #{(b[:gzip_kb] || "n/a").to_s.rjust(10)}" : ""
|
|
85
|
+
|
|
86
|
+
puts "#{b[:name].ljust(35)} #{b[:raw_kb].to_s.rjust(10)}#{gzip_col}#{flag}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless gzip_available
|
|
90
|
+
puts "\n Note: gzip sizes unavailable. Ensure `config.assets.compress = true` in production."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
puts "\n"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module ReactManifest
|
|
4
|
+
# Scans JSX/JS files using regex (no AST, no Node.js required).
|
|
5
|
+
#
|
|
6
|
+
# Phase 1 — builds a symbol index from shared dirs:
|
|
7
|
+
# "PrimaryButton" => "ux/components/buttons/primary_button"
|
|
8
|
+
# "useFetch" => "ux/hooks/use_fetch"
|
|
9
|
+
# "formatDate" => "ux/lib/format_date"
|
|
10
|
+
#
|
|
11
|
+
# Phase 2 — scans controller files for usage of those symbols
|
|
12
|
+
# and produces per-controller lists of referenced shared files.
|
|
13
|
+
#
|
|
14
|
+
# Phase 3 — emits non-fatal warnings.
|
|
15
|
+
class Scanner
|
|
16
|
+
# Patterns to detect symbol definitions (no import/export in this codebase)
|
|
17
|
+
DEFINITION_PATTERNS = [
|
|
18
|
+
/(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/, # const FooBar =
|
|
19
|
+
/function\s+([A-Z][A-Za-z0-9_]*)\s*\(/, # function FooBar(
|
|
20
|
+
/class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/, # class FooBar
|
|
21
|
+
/(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/, # const useFoo = (hooks)
|
|
22
|
+
/function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/, # function useFoo(
|
|
23
|
+
/(?:const|let|var)\s+([a-z][A-Za-z0-9_]{2,})\s*=\s*(?:function|\()/, # const formatDate = function/arrow
|
|
24
|
+
/^function\s+([a-z][A-Za-z0-9_]{2,})\s*\(/, # function formatDate( at line start
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Patterns to detect usage in controller files
|
|
28
|
+
JSX_ELEMENT_PATTERN = /<([A-Z][A-Za-z0-9_]*)[\s\/>]/.freeze
|
|
29
|
+
REACT_CREATE_PATTERN = /React\.createElement\(\s*([A-Z][A-Za-z0-9_]*)[\s,)]/.freeze
|
|
30
|
+
HOOK_CALL_PATTERN = /\b(use[A-Z][A-Za-z0-9_]*)\s*\(/.freeze
|
|
31
|
+
# Lib calls matched against known lib symbols to reduce false positives
|
|
32
|
+
LIB_CALL_PATTERN = /\b([a-z][A-Za-z0-9_]{2,})\s*\(/.freeze
|
|
33
|
+
|
|
34
|
+
# Common JS built-ins to exclude from lib-call matching
|
|
35
|
+
JS_BUILTINS = %w[
|
|
36
|
+
require function return typeof instanceof delete void
|
|
37
|
+
console document window location history navigator
|
|
38
|
+
setTimeout setInterval clearTimeout clearInterval
|
|
39
|
+
parseInt parseFloat isNaN isFinite encodeURI decodeURI
|
|
40
|
+
fetch Promise Object Array String Number Boolean Math JSON
|
|
41
|
+
Object Array String Number Boolean Symbol Map Set WeakMap
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
Result = Struct.new(:symbol_index, :controller_usages, :warnings, keyword_init: true)
|
|
45
|
+
|
|
46
|
+
def initialize(config = ReactManifest.configuration)
|
|
47
|
+
@config = config
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scan(classification)
|
|
51
|
+
warnings = []
|
|
52
|
+
symbol_index = {}
|
|
53
|
+
|
|
54
|
+
# Phase 1: index symbols from shared dirs
|
|
55
|
+
classification.shared_dirs.each do |shared_dir|
|
|
56
|
+
js_files_in(shared_dir[:path]).each do |file_path|
|
|
57
|
+
relative = relative_require_path(file_path)
|
|
58
|
+
symbols = extract_definitions(file_path)
|
|
59
|
+
symbols.each do |sym|
|
|
60
|
+
if symbol_index.key?(sym)
|
|
61
|
+
warnings << "Duplicate symbol '#{sym}' in #{relative} (already from #{symbol_index[sym]})"
|
|
62
|
+
else
|
|
63
|
+
symbol_index[sym] = relative
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if @config.verbose?
|
|
70
|
+
$stdout.puts "[ReactManifest] Shared symbol index: #{symbol_index.size} symbols indexed"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Phase 2: scan controller dirs for usage
|
|
74
|
+
controller_usages = {}
|
|
75
|
+
|
|
76
|
+
classification.controller_dirs.each do |ctrl|
|
|
77
|
+
files = js_files_in(ctrl[:path])
|
|
78
|
+
used = Set.new
|
|
79
|
+
|
|
80
|
+
if files.empty? && @config.verbose?
|
|
81
|
+
warnings << "Controller dir '#{ctrl[:name]}' has no JS/JSX files"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
files.each do |file_path|
|
|
85
|
+
validate_naming(file_path, ctrl[:name], warnings)
|
|
86
|
+
content = File.read(file_path, encoding: "utf-8")
|
|
87
|
+
|
|
88
|
+
# JSX element usage: <PrimaryButton (JSX tag syntax)
|
|
89
|
+
content.scan(JSX_ELEMENT_PATTERN) do |match|
|
|
90
|
+
sym = match[0]
|
|
91
|
+
if symbol_index.key?(sym)
|
|
92
|
+
used << symbol_index[sym]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# React.createElement(PrimaryButton, ...) (non-JSX style)
|
|
97
|
+
content.scan(REACT_CREATE_PATTERN) do |match|
|
|
98
|
+
sym = match[0]
|
|
99
|
+
if symbol_index.key?(sym)
|
|
100
|
+
used << symbol_index[sym]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Hook calls: useFetch(
|
|
105
|
+
content.scan(HOOK_CALL_PATTERN) do |match|
|
|
106
|
+
sym = match[0]
|
|
107
|
+
if symbol_index.key?(sym)
|
|
108
|
+
used << symbol_index[sym]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Lib function calls: formatDate( — filtered against lib symbol index
|
|
113
|
+
content.scan(LIB_CALL_PATTERN) do |match|
|
|
114
|
+
sym = match[0]
|
|
115
|
+
next if JS_BUILTINS.include?(sym)
|
|
116
|
+
if symbol_index.key?(sym)
|
|
117
|
+
used << symbol_index[sym]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
controller_usages[ctrl[:name]] = used.to_a.sort
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Phase 3: additional warnings
|
|
126
|
+
emit_fanout_warnings(controller_usages, warnings)
|
|
127
|
+
|
|
128
|
+
Result.new(
|
|
129
|
+
symbol_index: symbol_index,
|
|
130
|
+
controller_usages: controller_usages,
|
|
131
|
+
warnings: warnings
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def js_files_in(dir)
|
|
138
|
+
return [] unless Dir.exist?(dir)
|
|
139
|
+
Dir.glob(File.join(dir, "**", "*.{js,js.jsx}"))
|
|
140
|
+
.reject { |f| File.directory?(f) }
|
|
141
|
+
.reject { |f| excluded_path?(f) }
|
|
142
|
+
.sort
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns true if the file path contains a segment matching any exclude_path.
|
|
146
|
+
def excluded_path?(abs_path)
|
|
147
|
+
parts = abs_path.split(File::SEPARATOR)
|
|
148
|
+
@config.exclude_paths.any? { |ep| parts.include?(ep) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_definitions(file_path)
|
|
152
|
+
content = File.read(file_path, encoding: "utf-8")
|
|
153
|
+
symbols = []
|
|
154
|
+
DEFINITION_PATTERNS.each do |pattern|
|
|
155
|
+
content.scan(pattern) { |m| symbols << m[0] }
|
|
156
|
+
end
|
|
157
|
+
symbols.uniq
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def relative_require_path(abs_path)
|
|
161
|
+
# Build relative to output_dir (configurable) rather than a hardcoded path.
|
|
162
|
+
base = @config.abs_output_dir + File::SEPARATOR
|
|
163
|
+
rel = abs_path.sub(base, "")
|
|
164
|
+
# Strip Sprockets-understood extensions: .js.jsx → "", .jsx → "", .js → ""
|
|
165
|
+
rel.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate_naming(file_path, ctrl_name, warnings)
|
|
169
|
+
basename = File.basename(file_path, ".*").sub(/\.js$/, "")
|
|
170
|
+
# Expected: <controller>_index, <controller>_show, <controller>_form, etc.
|
|
171
|
+
unless basename.start_with?("#{ctrl_name}_") || basename == ctrl_name
|
|
172
|
+
warnings << "File '#{File.basename(file_path)}' in '#{ctrl_name}' does not follow " \
|
|
173
|
+
"'#{ctrl_name}_<action>.js.jsx' naming convention"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def emit_fanout_warnings(controller_usages, warnings)
|
|
178
|
+
# Count how many controllers use each shared file
|
|
179
|
+
fanout = Hash.new(0)
|
|
180
|
+
controller_usages.each_value do |files|
|
|
181
|
+
files.each { |f| fanout[f] += 1 }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
fanout.each do |file, count|
|
|
185
|
+
if count > 3
|
|
186
|
+
warnings << "High fan-out: '#{file}' is used by #{count} controllers " \
|
|
187
|
+
"(consider ensuring it's in the shared bundle)"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module ReactManifest
|
|
2
|
+
# Walks the ux/ directory tree and classifies subdirectories into:
|
|
3
|
+
# - controller_dirs: immediate subdirs of ux/app/ (each gets a ux_<name>.js)
|
|
4
|
+
# - shared_dirs: everything else directly under ux/ (feeds ux_shared.js)
|
|
5
|
+
#
|
|
6
|
+
# No hard-coded dir names — anything that is not app_dir is shared.
|
|
7
|
+
class TreeClassifier
|
|
8
|
+
Result = Struct.new(:controller_dirs, :shared_dirs, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(config = ReactManifest.configuration)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def classify
|
|
15
|
+
controller_dirs = []
|
|
16
|
+
shared_dirs = []
|
|
17
|
+
|
|
18
|
+
unless Dir.exist?(@config.abs_ux_root)
|
|
19
|
+
warn "[ReactManifest] ux_root does not exist: #{@config.abs_ux_root}. " \
|
|
20
|
+
"Create the directory and run `rails react_manifest:generate`."
|
|
21
|
+
return Result.new(controller_dirs: [], shared_dirs: [])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
Dir.children(@config.abs_ux_root).sort.each do |entry|
|
|
26
|
+
full_path = File.join(@config.abs_ux_root, entry)
|
|
27
|
+
next unless File.directory?(full_path)
|
|
28
|
+
|
|
29
|
+
if entry == @config.app_dir
|
|
30
|
+
begin
|
|
31
|
+
Dir.children(full_path).sort.each do |ctrl_entry|
|
|
32
|
+
ctrl_path = File.join(full_path, ctrl_entry)
|
|
33
|
+
next unless File.directory?(ctrl_path)
|
|
34
|
+
next if @config.ignore.include?(ctrl_entry)
|
|
35
|
+
|
|
36
|
+
controller_dirs << {
|
|
37
|
+
name: ctrl_entry,
|
|
38
|
+
path: ctrl_path,
|
|
39
|
+
bundle_name: "ux_#{ctrl_entry}"
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
rescue Errno::EACCES => e
|
|
43
|
+
warn "[ReactManifest] Permission denied reading #{full_path}: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
shared_dirs << {
|
|
47
|
+
name: entry,
|
|
48
|
+
path: full_path
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
rescue Errno::EACCES => e
|
|
53
|
+
warn "[ReactManifest] Permission denied reading ux_root #{@config.abs_ux_root}: #{e.message}"
|
|
54
|
+
return Result.new(controller_dirs: [], shared_dirs: [])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Result.new(controller_dirs: controller_dirs, shared_dirs: shared_dirs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Watch ux_root recursively so newly added controller directories
|
|
61
|
+
# are automatically detected without restarting the development server.
|
|
62
|
+
def watched_dirs
|
|
63
|
+
return [] unless Dir.exist?(@config.abs_ux_root)
|
|
64
|
+
dirs = [@config.abs_ux_root]
|
|
65
|
+
dirs << @config.abs_app_dir if Dir.exist?(@config.abs_app_dir)
|
|
66
|
+
dirs
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module ReactManifest
|
|
2
|
+
# Provides the `react_bundle_tag` view helper, included in ActionView::Base.
|
|
3
|
+
#
|
|
4
|
+
# Usage in layouts:
|
|
5
|
+
# <%= react_bundle_tag defer: true %>
|
|
6
|
+
#
|
|
7
|
+
# Resolves which ux_*.js bundles to include based on controller_path:
|
|
8
|
+
# 1. Always includes config.shared_bundle (e.g. "ux_shared")
|
|
9
|
+
# 2. Always appends config.always_include (e.g. ["ux_main"])
|
|
10
|
+
# 3. Appends "ux_<controller_path>" if that bundle file exists
|
|
11
|
+
# 4. For namespaced controllers (admin/users): checks ux_admin_users, then ux_admin
|
|
12
|
+
# 5. Returns "" for pure ERB/HAML pages with no matching bundle, or when
|
|
13
|
+
# called outside a controller context (mailers, engines, etc.)
|
|
14
|
+
module ViewHelpers
|
|
15
|
+
def react_bundle_tag(**html_options)
|
|
16
|
+
# controller_path is not available in all view contexts (mailers, engines,
|
|
17
|
+
# views rendered outside a request). Fall back gracefully rather than raising.
|
|
18
|
+
ctrl = respond_to?(:controller_path, true) ? controller_path : nil
|
|
19
|
+
return "".html_safe if ctrl.nil?
|
|
20
|
+
|
|
21
|
+
bundles = ReactManifest.resolve_bundles(ctrl)
|
|
22
|
+
return "".html_safe if bundles.empty?
|
|
23
|
+
|
|
24
|
+
javascript_include_tag(*bundles, **html_options)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|