react-manifest-rails 0.2.27 → 0.2.29

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95a89b37c1332d074e881e5676c346540b069ecbeafc7dd42526ce53f24a0285
4
- data.tar.gz: e351a53101a5b3c91f31e8fd9279e221fd0a345c56d7f6d003ca5a6d5280624f
3
+ metadata.gz: 8688e621b55d1a5b9db497668b4942fc97c8f4aad5f7778c7a7fc4a412990d04
4
+ data.tar.gz: c46872893e465f9684c68688a8f8ca02d361afd7bfa8421eb1b69b0180f8c3d9
5
5
  SHA512:
6
- metadata.gz: 5fa34a6989ccedc7a1b459e3ba68365ec16c82606069c319c7c093d7440cfe4a7e223a4e617e4b25fa1f4b5ab345c73e80fd2f0e0d12d0773121d5221cb2aefa
7
- data.tar.gz: eb604c20d10c84b9bf0abd6091e346eed18cb05063d88de4f1f8ec8e034dfe399db51c3c66d4ca0d7f8d67e9f1e57cc0c84823804c5da45e5d04e23467a4b55b
6
+ metadata.gz: 76a4b4aed865531470d641be4bb0d6608d30410d66463aad17e71b37a73acafe2fb4a825c5f3f89c0bc5e6a91d7016396699572d575950dca71a242378cbc6f3
7
+ data.tar.gz: 2bbd77ad7db859306d6cfdb6a75a476bfca219b1e22cf0b3aa54bd12f3c42d8a441c35a1e07dbb4e5a0c3c198b968683a15f814fd64bf59a55a91b1ca3d2b9d1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.28] - 2026-05-11
11
+
12
+ ### Fixed
13
+ - Boot generation now runs after app initializers (`config/initializers/`) have loaded, so the generator always uses the fully-configured `ux_root` and related settings. Previously the initializer ran during the Railtie phase (before `config/initializers/`), causing it to silently generate against default paths and produce unchanged manifests.
14
+
10
15
  ## [0.2.27] - 2026-05-11
11
16
 
12
17
  ### Fixed
@@ -6,6 +6,8 @@ module ReactManifest
6
6
  #
7
7
  # Produces a human-readable report without writing anything.
8
8
  class ApplicationAnalyzer
9
+ include ReactManifest::Logging
10
+
9
11
  DIRECTIVE_PATTERN = %r{^\s*//=\s+(require(?:_tree|_directory)?)\s+(.+)$}
10
12
 
11
13
  # Libs we recognise as vendor (case-insensitive partial match on the require path)
@@ -47,30 +49,30 @@ module ReactManifest
47
49
 
48
50
  # Pretty-print the analysis report to stdout
49
51
  def print_report(results = analyze)
50
- puts "\n=== ReactManifest: Application Manifest Analysis ===\n"
52
+ log_info "\n=== ReactManifest: Application Manifest Analysis ===\n"
51
53
 
52
54
  if results.empty?
53
- puts "No application*.js files found in #{@config.abs_output_dir}"
55
+ log_info "No application*.js files found in #{@config.abs_output_dir}"
54
56
  return
55
57
  end
56
58
 
57
59
  results.each do |result|
58
60
  rel = result.file.sub("#{Rails.root}/", "")
59
61
  status = result.clean? ? "✓ already clean" : "⚠ needs migration"
60
- puts "\n#{rel} [#{status}]"
61
- puts "-" * 60
62
+ log_info "\n#{rel} [#{status}]"
63
+ log_info "-" * 60
62
64
 
63
65
  icon_map = { vendor: " ✓ KEEP ", ux_code: " ✗ REMOVE ", unknown: " ? REVIEW " }
64
66
  result.directives.each do |d|
65
67
  icon = icon_map[d.classification]
66
- puts "#{icon} #{d.original_line.strip}"
67
- puts " → #{d.note}" if d.note
68
+ log_info "#{icon} #{d.original_line.strip}"
69
+ log_info " → #{d.note}" if d.note
68
70
  end
69
71
  end
70
72
 
71
- puts "\n"
72
- puts "Run `rails react_manifest:migrate_application` to apply changes."
73
- puts "Use `--dry-run` (or config.dry_run = true) to preview first.\n\n"
73
+ log_info "\n"
74
+ log_info "Run `rails react_manifest:migrate_application` to apply changes."
75
+ log_info "Use `--dry-run` (or config.dry_run = true) to preview first.\n\n"
74
76
  end
75
77
 
76
78
  private
@@ -8,6 +8,8 @@ module ReactManifest
8
8
  # - Never removes :vendor or :passthrough lines
9
9
  # - Adds a managed-by comment at the top
10
10
  class ApplicationMigrator
11
+ include ReactManifest::Logging
12
+
11
13
  MANAGED_COMMENT = <<~JS.freeze
12
14
  // Non-UX libraries — loaded on every page.
13
15
  // React app code is now served per-controller via react_bundle_tag.
@@ -30,13 +32,13 @@ module ReactManifest
30
32
  results = @analyzer.analyze
31
33
 
32
34
  if results.empty?
33
- $stdout.puts "[ReactManifest] No application*.js files found to migrate."
35
+ log_info "No application*.js files found to migrate."
34
36
  return []
35
37
  end
36
38
 
37
39
  results.map do |result|
38
40
  if result.clean?
39
- $stdout.puts "[ReactManifest] #{short(result.file)} — already clean, skipping."
41
+ log_info "#{short(result.file)} — already clean, skipping."
40
42
  { file: result.file, status: :already_clean }
41
43
  else
42
44
  rewrite(result)
@@ -51,7 +53,7 @@ module ReactManifest
51
53
  new_content = build_new_content(result)
52
54
 
53
55
  if @config.dry_run?
54
- $stdout.puts "\n[ReactManifest] DRY-RUN: #{short(file)}"
56
+ log_info "DRY-RUN: #{short(file)}"
55
57
  print_diff(file, new_content)
56
58
  return { file: file, status: :dry_run }
57
59
  end
@@ -61,15 +63,22 @@ module ReactManifest
61
63
  begin
62
64
  FileUtils.cp(file, bak_path)
63
65
  File.chmod(0o600, bak_path)
64
- $stdout.puts "[ReactManifest] Backup: #{short(bak_path)}"
66
+ log_info "Backup: #{short(bak_path)}"
65
67
  rescue StandardError => e
66
- $stdout.puts "[ReactManifest] ERROR: Could not create backup of #{short(file)}: #{e.message}"
67
- $stdout.puts "[ReactManifest] Migration aborted for #{short(file)} — original file unchanged."
68
+ log_warn "ERROR: Could not create backup of #{short(file)}: #{e.message}"
69
+ log_warn "Migration aborted for #{short(file)} — original file unchanged."
68
70
  return { file: file, status: :backup_failed, error: e.message }
69
71
  end
70
72
 
71
- File.write(file, new_content, encoding: "utf-8")
72
- $stdout.puts "[ReactManifest] Migrated: #{short(file)}"
73
+ tmp = "#{file}.tmp.#{Process.pid}"
74
+ begin
75
+ File.write(tmp, new_content, encoding: "utf-8")
76
+ File.rename(tmp, file)
77
+ rescue StandardError => e
78
+ FileUtils.rm_f(tmp)
79
+ raise e
80
+ end
81
+ log_info "Migrated: #{short(file)}"
73
82
 
74
83
  { file: file, status: :migrated, backup: bak_path }
75
84
  end
@@ -120,8 +129,8 @@ module ReactManifest
120
129
  removed = old_lines - new_lines
121
130
  added = new_lines - old_lines
122
131
 
123
- removed.each { |l| $stdout.puts " - #{l}" }
124
- added.each { |l| $stdout.puts " + #{l}" }
132
+ removed.each { |l| log_info " - #{l}" }
133
+ added.each { |l| log_info " + #{l}" }
125
134
  end
126
135
 
127
136
  def short(path)
@@ -135,8 +135,17 @@ module ReactManifest
135
135
  File.join(abs_output_dir, subdir)
136
136
  end
137
137
 
138
+ def excluded_path?(abs_path)
139
+ parts = abs_path.split(File::SEPARATOR)
140
+ exclude_paths.any? { |ep| parts.include?(ep) }
141
+ end
142
+
138
143
  def normalized_manifest_subdir
139
144
  manifest_subdir.to_s.gsub(%r{\A/+|/+\z}, "")
140
145
  end
146
+
147
+ def cache_key
148
+ [ux_root, app_dir, extensions, always_include, exclude_paths, external_providers].hash
149
+ end
141
150
  end
142
151
  end
@@ -9,6 +9,8 @@ module ReactManifest
9
9
  # map.shared_files_for("users") # => ["ux/components/...", ...]
10
10
  # map.controllers_using("ux/lib/api_helpers") # => ["users", "admin"]
11
11
  class DependencyMap
12
+ include ReactManifest::Logging
13
+
12
14
  attr_reader :symbol_index, :controller_usages, :warnings
13
15
 
14
16
  def initialize(scan_result)
@@ -40,29 +42,29 @@ module ReactManifest
40
42
 
41
43
  # Pretty-print for the analyze rake task
42
44
  def print_report
43
- puts "\n=== ReactManifest Dependency Analysis ===\n\n"
45
+ log_info "\n=== ReactManifest Dependency Analysis ===\n\n"
44
46
 
45
- puts "Shared Symbol Index (#{@symbol_index.size} symbols):"
47
+ log_info "Shared Symbol Index (#{@symbol_index.size} symbols):"
46
48
  @symbol_index.each do |sym, file|
47
49
  # Strip non-printable/control characters to prevent terminal manipulation
48
50
  safe_sym = sym.gsub(/[^\x20-\x7E]/, "?")
49
51
  safe_file = file.gsub(/[^\x20-\x7E]/, "?")
50
- puts " #{safe_sym.ljust(40)} #{safe_file}"
52
+ log_info " #{safe_sym.ljust(40)} #{safe_file}"
51
53
  end
52
54
 
53
- puts "\nPer-Controller Usage:"
55
+ log_info "\nPer-Controller Usage:"
54
56
  @controller_usages.each do |ctrl, files|
55
- puts "\n [#{ctrl}] (#{files.size} shared references)"
56
- files.each { |f| puts " #{f}" }
57
- puts " (none)" if files.empty?
57
+ log_info "\n [#{ctrl}] (#{files.size} shared references)"
58
+ files.each { |f| log_info " #{f}" }
59
+ log_info " (none)" if files.empty?
58
60
  end
59
61
 
60
62
  unless @warnings.empty?
61
- puts "\nWarnings (#{@warnings.size}):"
62
- @warnings.each { |w| puts " ⚠ #{w}" }
63
+ log_info "\nWarnings (#{@warnings.size}):"
64
+ @warnings.each { |w| log_warn " ⚠ #{w}" }
63
65
  end
64
66
 
65
- puts "\n"
67
+ log_info "\n"
66
68
  end
67
69
  end
68
70
  end
@@ -1,5 +1,6 @@
1
1
  require "digest"
2
2
  require "tmpdir"
3
+ require_relative "path_utils"
3
4
 
4
5
  module ReactManifest
5
6
  # Generates all ux_*.js Sprockets manifest files.
@@ -24,6 +25,9 @@ module ReactManifest
24
25
  # Never touches application.js, application_dev.js, or files in exclude_paths.
25
26
  # rubocop:disable Metrics/ClassLength
26
27
  class Generator
28
+ include PathUtils
29
+ include ReactManifest::Logging
30
+
27
31
  HEADER = <<~JS.freeze
28
32
  // AUTO-GENERATED — DO NOT EDIT
29
33
  // react-manifest-rails %<version>s
@@ -42,9 +46,7 @@ module ReactManifest
42
46
  # written and others stale/missing.
43
47
  def run!
44
48
  classification = @classifier.classify
45
- scan_result = Scanner.new(@config).scan(classification)
46
- controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs,
47
- scan_result)
49
+ controller_context = build_controller_context(classification.controller_dirs)
48
50
 
49
51
  # Phase 1: build all content in memory — no I/O.
50
52
  shared_manifest = build_shared(classification.shared_dirs)
@@ -61,6 +63,28 @@ module ReactManifest
61
63
  results
62
64
  end
63
65
 
66
+ # Remove all AUTO-GENERATED ux_*.js manifests. Silently skips files that
67
+ # disappear between the directory scan and the read (TOCTOU-safe).
68
+ # Returns { removed: N, skipped: N }.
69
+ def clean!
70
+ targets = [@config.abs_manifest_dir, @config.abs_output_dir].uniq
71
+ removed = 0
72
+ skipped = 0
73
+
74
+ targets.each do |dir|
75
+ Dir.glob(File.join(dir, "ux_*.js")).each do |file|
76
+ if auto_generated?(file)
77
+ File.delete(file)
78
+ removed += 1
79
+ else
80
+ skipped += 1
81
+ end
82
+ end
83
+ end
84
+
85
+ { removed: removed, skipped: skipped }
86
+ end
87
+
64
88
  private
65
89
 
66
90
  # ------------------------------------------------------------------ shared
@@ -88,8 +112,6 @@ module ReactManifest
88
112
  lines = header_lines
89
113
  always_include_reqs = controller_context[:always_include_requires].fetch(ctrl[:bundle_name], [])
90
114
  dep_requires = controller_dependency_requires(ctrl[:bundle_name], controller_context)
91
- controller_context[:shared_lib_requires]
92
- controller_context[:shared_requires].fetch(ctrl[:bundle_name], Set.new).to_a.sort
93
115
  ext_reqs = controller_context[:external_requires].fetch(ctrl[:bundle_name], Set.new).to_a.sort
94
116
 
95
117
  files = js_files_in(ctrl[:path])
@@ -105,25 +127,14 @@ module ReactManifest
105
127
  { filename: "#{ctrl[:bundle_name]}.js", content: "#{lines.join("\n")}\n" }
106
128
  end
107
129
 
108
- # rubocop:disable Metrics/AbcSize
109
- def build_controller_context(controller_dirs, shared_dirs, scan_result)
130
+ def build_controller_context(controller_dirs)
110
131
  bundle_files = {}
111
132
  symbol_to_bundle = {}
112
133
  external_symbol_to_require = {}
113
134
  dependencies = Hash.new { |h, k| h[k] = Set.new }
114
135
  external_requires = Hash.new { |h, k| h[k] = Set.new }
115
- shared_require_paths = shared_require_path_set(shared_dirs)
116
- shared_requires = Hash.new { |h, k| h[k] = Set.new }
117
- shared_dependency_map = build_shared_dependency_map(shared_dirs, shared_require_paths, scan_result)
118
- shared_lib_requires = shared_lib_require_paths(shared_dirs)
119
136
 
120
137
  controller_dirs.each do |ctrl|
121
- scan_result.controller_usages.fetch(ctrl[:name], []).each do |req_path|
122
- shared_requires[ctrl[:bundle_name]] << req_path
123
- end
124
- shared_requires[ctrl[:bundle_name]] = expand_shared_requires(shared_requires[ctrl[:bundle_name]],
125
- shared_dependency_map)
126
-
127
138
  # Index controller-defined symbols for cross-app detection
128
139
  bundle_name = ctrl[:bundle_name]
129
140
  files = js_files_in(ctrl[:path])
@@ -176,12 +187,9 @@ module ReactManifest
176
187
  bundle_files: bundle_files,
177
188
  dependencies: dependencies,
178
189
  always_include_requires: always_include_requires,
179
- shared_lib_requires: shared_lib_requires,
180
- shared_requires: shared_requires,
181
190
  external_requires: external_requires
182
191
  }
183
192
  end
184
- # rubocop:enable Metrics/AbcSize
185
193
 
186
194
  def controller_dependency_requires(bundle_name, controller_context)
187
195
  deps = transitive_dependencies(bundle_name, controller_context[:dependencies])
@@ -254,7 +262,7 @@ module ReactManifest
254
262
  end
255
263
 
256
264
  if @config.dry_run?
257
- $stdout.puts "[ReactManifest] DRY-RUN: would write #{dest}"
265
+ log_info "DRY-RUN: would write #{dest}"
258
266
  print_diff(dest, content)
259
267
  return { path: dest, status: :dry_run }
260
268
  end
@@ -286,7 +294,7 @@ module ReactManifest
286
294
  if @config.dry_run?
287
295
  legacy_files.each do |legacy|
288
296
  target = File.join(manifest_dir, File.basename(legacy))
289
- $stdout.puts "[ReactManifest] DRY-RUN: would move #{legacy} -> #{target}"
297
+ log_info "DRY-RUN: would move #{legacy} -> #{target}"
290
298
  end
291
299
  return
292
300
  end
@@ -320,7 +328,7 @@ module ReactManifest
320
328
  files = Dir.glob(File.join(dir, "**", @config.extensions_glob))
321
329
  .reject { |f| File.directory?(f) }
322
330
  .reject { |f| auto_generated?(f) }
323
- .reject { |f| excluded_path?(f) }
331
+ .reject { |f| @config.excluded_path?(f) }
324
332
  .sort
325
333
 
326
334
  # Deduplicate by logical require path: if both foo.js and foo.jsx exist,
@@ -336,20 +344,11 @@ module ReactManifest
336
344
  end
337
345
  end
338
346
 
339
- # Returns true if the file path contains a component matching any exclude_path.
340
- # exclude_paths are matched against individual path segments, so "vendor" matches
341
- # ux/vendor/foo.js but not ux/vendor_custom/foo.js.
342
- def excluded_path?(abs_path)
343
- parts = abs_path.split(File::SEPARATOR)
344
- @config.exclude_paths.any? { |ep| parts.include?(ep) }
345
- end
346
-
347
347
  def relative_require_path(abs_path)
348
348
  # Build relative to output_dir (configurable) rather than a hardcoded path.
349
349
  base = @config.abs_output_dir + File::SEPARATOR
350
350
  rel = abs_path.sub(base, "")
351
- # Strip Sprockets-understood extensions: .js.jsx/.jsx/.js -> logical path.
352
- rel.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
351
+ strip_asset_extension(rel)
353
352
  end
354
353
 
355
354
  def extract_defined_symbols(file_path)
@@ -365,22 +364,7 @@ module ReactManifest
365
364
 
366
365
  def extract_used_component_symbols(file_path)
367
366
  content = File.read(file_path, encoding: "utf-8")
368
-
369
- # Collect locally-defined symbols to avoid self-reference false positives
370
- local_syms = Set.new
371
- ReactManifest::Scanner::DEFINITION_PATTERNS.each do |pattern|
372
- content.scan(pattern) { |m| local_syms << m[0] }
373
- end
374
-
375
- symbols = []
376
- content.scan(ReactManifest::Scanner::PASCAL_TOKEN_PATTERN) do |m|
377
- symbols << m[0] unless local_syms.include?(m[0])
378
- end
379
- content.scan(ReactManifest::Scanner::HOOK_TOKEN_PATTERN) do |m|
380
- symbols << m[0] unless local_syms.include?(m[0])
381
- end
382
-
383
- symbols.uniq
367
+ SymbolExtractor.extract_usages(content)
384
368
  rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
385
369
  []
386
370
  end
@@ -390,7 +374,7 @@ module ReactManifest
390
374
 
391
375
  Dir.glob(File.join(dir, "**", @config.extensions_glob))
392
376
  .reject { |f| File.directory?(f) }
393
- .reject { |f| excluded_path?(f) }
377
+ .reject { |f| @config.excluded_path?(f) }
394
378
  .sort
395
379
  end
396
380
 
@@ -400,68 +384,8 @@ module ReactManifest
400
384
  Rails.root.join(path).to_s
401
385
  end
402
386
 
403
- def shared_require_path_set(shared_dirs)
404
- shared_dirs.each_with_object(Set.new) do |shared_dir, paths|
405
- js_files_in(shared_dir[:path]).each do |file_path|
406
- paths << normalize_require_path(relative_require_path(file_path))
407
- end
408
- end
409
- end
410
-
411
- def shared_lib_require_paths(shared_dirs)
412
- shared_dirs.each_with_object([]) do |shared_dir, paths|
413
- next unless File.basename(shared_dir[:path]) == "lib"
414
-
415
- js_files_in(shared_dir[:path]).each do |file_path|
416
- paths << normalize_require_path(relative_require_path(file_path))
417
- end
418
- end.sort.uniq
419
- end
420
-
421
- def build_shared_dependency_map(shared_dirs, shared_require_paths, scan_result)
422
- dependency_map = Hash.new { |h, k| h[k] = Set.new }
423
-
424
- shared_symbol_index = scan_result.symbol_index.each_with_object({}) do |(sym, req_path), index|
425
- normalized = normalize_require_path(req_path)
426
- next unless shared_require_paths.include?(normalized)
427
-
428
- index[sym] = normalized
429
- end
430
-
431
- shared_dirs.each do |shared_dir|
432
- js_files_in(shared_dir[:path]).each do |file_path|
433
- from_req = normalize_require_path(relative_require_path(file_path))
434
- extract_used_component_symbols(file_path).each do |sym|
435
- to_req = shared_symbol_index[sym]
436
- next if to_req.nil? || to_req == from_req
437
-
438
- dependency_map[from_req] << to_req
439
- end
440
- end
441
- end
442
-
443
- dependency_map
444
- end
445
-
446
- def expand_shared_requires(initial_requires, dependency_map)
447
- expanded = Set.new(initial_requires)
448
- queue = initial_requires.to_a
449
-
450
- until queue.empty?
451
- req = queue.shift
452
- dependency_map.fetch(req, Set.new).each do |dep_req|
453
- next if expanded.include?(dep_req)
454
-
455
- expanded << dep_req
456
- queue << dep_req
457
- end
458
- end
459
-
460
- expanded
461
- end
462
-
463
387
  def normalize_require_path(path)
464
- path.to_s.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
388
+ strip_asset_extension(path)
465
389
  end
466
390
 
467
391
  def warn_on_external_controller_references(file_path, symbol_to_bundle)
@@ -469,9 +393,9 @@ module ReactManifest
469
393
  dep_bundle = symbol_to_bundle[sym]
470
394
  next unless dep_bundle
471
395
 
472
- warn "[ReactManifest] External file '#{relative_require_path(file_path)}' references " \
473
- "controller-only symbol '#{sym}' (#{dep_bundle}). " \
474
- "Move '#{sym}' to a shared ux dir to avoid duplicate runtime declarations."
396
+ log_warn "External file '#{relative_require_path(file_path)}' references " \
397
+ "controller-only symbol '#{sym}' (#{dep_bundle}). " \
398
+ "Move '#{sym}' to a shared ux dir to avoid duplicate runtime declarations."
475
399
  end
476
400
  end
477
401
 
@@ -492,18 +416,18 @@ module ReactManifest
492
416
  removed = old_lines - new_lines
493
417
  added = new_lines - old_lines
494
418
 
495
- removed.each { |l| $stdout.puts " - #{l.chomp}" }
496
- added.each { |l| $stdout.puts " + #{l.chomp}" }
419
+ removed.each { |l| log_info " - #{l.chomp}" }
420
+ added.each { |l| log_info " + #{l.chomp}" }
497
421
  else
498
- new_content.each_line { |l| $stdout.puts " + #{l.chomp}" }
422
+ new_content.each_line { |l| log_info " + #{l.chomp}" }
499
423
  end
500
424
  end
501
425
 
502
426
  def print_summary(results)
503
427
  counts = results.group_by { |r| r[:status] }.transform_values(&:count)
504
- $stdout.puts "[ReactManifest] Generated: #{counts[:written] || 0} written, " \
505
- "#{counts[:unchanged] || 0} unchanged, " \
506
- "#{counts[:skipped_pinned] || 0} skipped (not auto-generated)"
428
+ log_info "Generated: #{counts[:written] || 0} written, " \
429
+ "#{counts[:unchanged] || 0} unchanged, " \
430
+ "#{counts[:skipped_pinned] || 0} skipped (not auto-generated)"
507
431
  end
508
432
  end
509
433
  # rubocop:enable Metrics/ClassLength
@@ -9,6 +9,8 @@ module ReactManifest
9
9
  # Usage:
10
10
  # ReactManifest::LayoutPatcher.new(config).patch!
11
11
  class LayoutPatcher
12
+ include ReactManifest::Logging
13
+
12
14
  LAYOUTS_GLOB = "app/views/layouts/*.html.{erb,haml,slim}".freeze
13
15
  BUNDLE_TAG_ERB = "<%= react_bundle_tag %>\n".freeze
14
16
  BUNDLE_TAG_HAML = "= react_bundle_tag\n".freeze
@@ -23,7 +25,7 @@ module ReactManifest
23
25
  def patch!
24
26
  layouts = find_layouts
25
27
  if layouts.empty?
26
- $stdout.puts "[ReactManifest] No layout files found in #{layouts_dir}"
28
+ log_info "No layout files found in #{layouts_dir}"
27
29
  return []
28
30
  end
29
31
  layouts.map { |f| patch_file(f) }
@@ -58,13 +60,20 @@ module ReactManifest
58
60
  end
59
61
 
60
62
  if @config.dry_run?
61
- $stdout.puts "[ReactManifest] DRY-RUN: would patch #{short(path)}"
63
+ log_info "DRY-RUN: would patch #{short(path)}"
62
64
  print_diff(content, new_content)
63
65
  return Result.new(file: path, status: :dry_run, detail: nil)
64
66
  end
65
67
 
66
- File.write(path, new_content, encoding: "utf-8")
67
- $stdout.puts "[ReactManifest] Patched layout: #{short(path)}"
68
+ tmp = "#{path}.tmp.#{Process.pid}"
69
+ begin
70
+ File.write(tmp, new_content, encoding: "utf-8")
71
+ File.rename(tmp, path)
72
+ rescue StandardError => e
73
+ FileUtils.rm_f(tmp)
74
+ raise e
75
+ end
76
+ log_info "Patched layout: #{short(path)}"
68
77
  Result.new(file: path, status: :patched, detail: nil)
69
78
  end
70
79
 
@@ -115,7 +124,7 @@ module ReactManifest
115
124
  def print_diff(old_content, new_content)
116
125
  old_lines = old_content.lines.map(&:chomp)
117
126
  new_lines = new_content.lines.map(&:chomp)
118
- (new_lines - old_lines).each { |l| $stdout.puts " + #{l}" }
127
+ (new_lines - old_lines).each { |l| log_info " + #{l}" }
119
128
  end
120
129
  end
121
130
  end
@@ -0,0 +1,31 @@
1
+ module ReactManifest
2
+ module Logging
3
+ def log_debug(message)
4
+ full = "[ReactManifest] #{message}"
5
+ Rails.logger.debug(full)
6
+ $stdout.puts(full) if stdout_logging_needed?
7
+ end
8
+
9
+ def log_info(message)
10
+ full = "[ReactManifest] #{message}"
11
+ Rails.logger.info(full)
12
+ $stdout.puts(full) if stdout_logging_needed?
13
+ end
14
+
15
+ def log_warn(message)
16
+ full = "[ReactManifest] #{message}"
17
+ Rails.logger.warn(full)
18
+ $stdout.puts(full) if stdout_logging_needed?
19
+ end
20
+
21
+ private
22
+
23
+ def stdout_logging_needed?
24
+ ReactManifest.configuration.stdout_logging? && !rails_console?
25
+ end
26
+
27
+ def rails_console?
28
+ defined?(Rails::Console)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ module ReactManifest
2
+ module PathUtils
3
+ # Matches compound and single Sprockets-understood asset extensions.
4
+ # Order is significant: compound forms must precede their singles.
5
+ STRIPPABLE_EXTENSIONS = /\.(ts\.tsx|js\.jsx|tsx|ts|jsx|js)$/
6
+
7
+ def strip_asset_extension(path)
8
+ path.to_s.sub(STRIPPABLE_EXTENSIONS, "")
9
+ end
10
+ end
11
+ end
@@ -3,28 +3,23 @@ require "rails/railtie"
3
3
  module ReactManifest
4
4
  class Railtie < Rails::Railtie
5
5
  # ----------------------------------------------------------------
6
- # In development, generate once on boot if expected manifests are missing.
7
- # This makes first-run setup deterministic even before any file change event.
6
+ # In development, always regenerate manifests on boot so that files
7
+ # added between restarts (e.g. via git merge) are picked up immediately.
8
+ # The generator is idempotent — it skips writes when content is unchanged.
8
9
  # ----------------------------------------------------------------
9
- initializer "react_manifest.ensure_manifests" do
10
+ initializer "react_manifest.ensure_manifests", after: :load_config_initializers do
10
11
  next unless Rails.env.development?
11
12
 
12
13
  config = ReactManifest.configuration
13
- # Private class method: call via send from the initializer instance context.
14
- missing = self.class.send(:missing_manifest_bundles, config)
15
- next if missing.empty?
16
-
17
- message = "[ReactManifest] Missing manifests on boot: #{missing.join(', ')}. Generating now..."
18
- Rails.logger&.info(message)
19
- $stdout.puts(message) if config.stdout_logging?
20
14
 
21
15
  begin
22
16
  results = ReactManifest::Generator.new(config).run!
23
17
  written = results.count { |r| r[:status] == :written }
24
- unchanged = results.count { |r| r[:status] == :unchanged }
25
- done = "[ReactManifest] Boot generation complete: #{written} written, #{unchanged} unchanged"
26
- Rails.logger&.info(done)
27
- $stdout.puts(done) if config.stdout_logging?
18
+ if written.positive?
19
+ done = "[ReactManifest] Boot generation complete: #{written} written"
20
+ Rails.logger&.info(done)
21
+ $stdout.puts(done) if config.stdout_logging?
22
+ end
28
23
  rescue StandardError => e
29
24
  error = "[ReactManifest] Could not generate manifests on boot: #{e.message}"
30
25
  Rails.logger&.warn(error)
@@ -99,21 +94,5 @@ module ReactManifest
99
94
  Rake::Task["assets:precompile"].enhance(["react_manifest:generate"])
100
95
  end
101
96
  end
102
-
103
- class << self
104
- private
105
-
106
- def missing_manifest_bundles(config)
107
- expected_manifest_bundles(config).reject do |bundle_name|
108
- File.exist?(File.join(config.abs_manifest_dir, "#{bundle_name}.js")) ||
109
- File.exist?(File.join(config.abs_output_dir, "#{bundle_name}.js"))
110
- end
111
- end
112
-
113
- def expected_manifest_bundles(config)
114
- classification = ReactManifest::TreeClassifier.new(config).classify
115
- ([config.shared_bundle] + classification.controller_dirs.map { |ctrl| ctrl[:bundle_name] }).uniq
116
- end
117
- end
118
97
  end
119
98
  end