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.
@@ -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,3 @@
1
+ module ReactManifest
2
+ VERSION = "0.1.0"
3
+ 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