module ReactOnRails::Utils

def self.bundle_js_file_path(bundle_name)

def self.bundle_js_file_path(bundle_name)
  # Priority order depends on bundle type:
  # SERVER BUNDLES (normal case): Try private non-public locations first, then manifest, then legacy
  # CLIENT BUNDLES (normal case): Try manifest first, then fallback locations
  if bundle_name == "manifest.json"
    # Default to the non-hashed name in the specified output directory, which, for legacy
    # React on Rails, this is the output directory picked up by the asset pipeline.
    # For Shakapacker, this is the public output path defined in the (shaka/web)packer.yml file.
    File.join(public_bundles_full_path, bundle_name)
  else
    bundle_js_file_path_with_packer(bundle_name)
  end
end

def self.bundle_js_file_path_with_packer(bundle_name)

def self.bundle_js_file_path_with_packer(bundle_name)
server_bundle?(bundle_name)
ls.configuration
oot || "."
and server_bundle_output_path is configured, return that path directly
 && config.server_bundle_output_path.present?
ndle_path = File.expand_path(File.join(root_path, config.server_bundle_output_path,
                                       bundle_name))
 to public directory if enforce_private_server_bundles is enabled
_private_server_bundles || File.exist?(private_server_bundle_path)
server_bundle_path
up for all bundles
kerUtils.bundle_js_uri_from_packer(bundle_name)
:Manifest::MissingEntryError
nifest_entry(bundle_name, is_server_bundle)

def self.default_troubleshooting_section

def self.default_troubleshooting_section
  <<~DEFAULT
    📞 Get Help & Support:
       • 🚀 Professional Support: react_on_rails@shakacode.com (fastest resolution)
       • 💬 React + Rails Slack: https://invite.reactrails.com
       • 🆓 GitHub Issues: https://github.com/shakacode/react_on_rails/issues
       • 📖 Discussions: https://github.com/shakacode/react_on_rails/discussions
  DEFAULT
end

def self.detect_package_manager

Returns:
  • (Symbol) - The package manager symbol (:npm, :yarn, :pnpm, :bun)
def self.detect_package_manager
  manager = detect_package_manager_from_package_json || detect_package_manager_from_lock_files
  manager || :yarn # Default to yarn if no detection succeeds
end

def self.detect_package_manager_from_lock_files

def self.detect_package_manager_from_lock_files
e.exist?(File.join(root, "yarn.lock"))
e.exist?(File.join(root, "pnpm-lock.yaml"))
.exist?(File.join(root, "bun.lockb"))
.exist?(File.join(root, "package-lock.json"))

def self.detect_package_manager_from_package_json

def self.detect_package_manager_from_package_json
 File.join(Rails.root, ReactOnRails.configuration.node_modules_location, "package.json")
ile.exist?(package_json_path)
 JSON.parse(File.read(package_json_path))
ackage_json_data["packageManager"]
ckage_json_data["packageManager"]
ame from strings like "yarn@3.6.0" or "pnpm@8.0.0"
ger_string.split("@").first
 if %w[npm yarn pnpm bun].include?(manager_name)

def self.find_most_recent_mtime(files)

def self.find_most_recent_mtime(files)
  files.reduce(1.year.ago) do |newest_time, file|
    mt = File.mtime(file)
    [mt, newest_time].max
  end
end

def self.full_text_errors_enabled?

def self.full_text_errors_enabled?
  ENV["FULL_TEXT_ERRORS"] == "true"
end

def self.gem_available?(name)

def self.gem_available?(name)
  Gem.loaded_specs[name].present?
rescue Gem::LoadError
  false
rescue StandardError
  begin
    Gem.available?(name).present?
  rescue NoMethodError
    false
  end
end

def self.generated_assets_full_path

DEPRECATED: Use public_bundles_full_path for clarity about public vs private bundle paths
def self.generated_assets_full_path
  public_bundles_full_path
end

def self.handle_missing_manifest_entry(bundle_name, is_server_bundle)

def self.handle_missing_manifest_entry(bundle_name, is_server_bundle)
ls.configuration
oot || "."
s with server_bundle_output_path configured, use that
 && config.server_bundle_output_path.present?
 [File.expand_path(File.join(root_path, config.server_bundle_output_path, bundle_name))]
orce_private_server_bundles
 << File.expand_path(File.join(ReactOnRails::PackerUtils.packer_public_output_path,
                               bundle_name))
ach do |path|
File.exist?(path)
paths.first
s and server bundles without special config, use packer's public path
environment-specific path configured in shakapacker.yml
le.join(ReactOnRails::PackerUtils.packer_public_output_path, bundle_name))

def self.immediate_hydration_pro_license_warning(name, type = "Component")

def self.immediate_hydration_pro_license_warning(name, type = "Component")
  "[REACT ON RAILS] Warning: immediate_hydration: true requires a React on Rails Pro license.\n" \
    "#{type} '#{name}' will fall back to standard hydration behavior.\n" \
    "Visit https://www.shakacode.com/react-on-rails-pro/ for licensing information."
end

def self.invoke_and_exit_if_failed(cmd, failure_message)

Invokes command, exiting with a detailed message if there's a failure.
def self.invoke_and_exit_if_failed(cmd, failure_message)
  stdout, stderr, status = Open3.capture3(cmd)
  unless status.success?
    stdout_msg = stdout.present? ? "\nstdout:\n#{stdout.strip}\n" : ""
    stderr_msg = stderr.present? ? "\nstderr:\n#{stderr.strip}\n" : ""
    msg = <<~MSG
      React on Rails FATAL ERROR!
      #{failure_message}
      cmd: #{cmd}
      exitstatus: #{status.exitstatus}#{stdout_msg}#{stderr_msg}
    MSG
    puts wrap_message(msg)
    puts ""
    puts default_troubleshooting_section
    # Rspec catches exit without! in the exit callbacks
    exit!(1)
  end
  [stdout, stderr, status]
end

def self.normalize_immediate_hydration(value, name, type = "Component")

Raises:
  • (ArgumentError) - If value is not a boolean or nil

Returns:
  • (Boolean) - The normalized immediate_hydration value

Parameters:
  • type (String) -- The type ("Component" or "Store") for warning messages
  • name (String) -- The name of the component/store (for warning messages)
  • value (Boolean, nil) -- The immediate_hydration option value
def self.normalize_immediate_hydration(value, name, type = "Component")
  # Type validation: only accept boolean or nil
  unless [true, false, nil].include?(value)
    raise ArgumentError,
          "[REACT ON RAILS] immediate_hydration must be true, false, or nil. Got: #{value.inspect} (#{value.class})"
  end
  # Strict equality check: only trigger warning for explicit boolean true
  if value == true && !react_on_rails_pro?
    Rails.logger.warn immediate_hydration_pro_license_warning(name, type)
    return false
  end
  # If nil, default based on Pro license status
  return react_on_rails_pro? if value.nil?
  # Return explicit value (including false)
  value
end

def self.object_to_boolean(value)

def self.object_to_boolean(value)
  [true, "true", "yes", 1, "1", "t"].include?(value.instance_of?(String) ? value.downcase : value)
end

def self.package_manager_install_exact_command(package_name, version)

Returns:
  • (String) - The command to run (e.g., "yarn add react-on-rails@16.0.0 --exact")

Parameters:
  • version (String) -- The exact version to install
  • package_name (String) -- The name of the package to install
def self.package_manager_install_exact_command(package_name, version)
  validate_package_command_inputs!(package_name, version)
  manager = detect_package_manager
  # Escape shell arguments to prevent command injection
  safe_package = Shellwords.escape("#{package_name}@#{version}")
  case manager
  when :pnpm
    "pnpm add #{safe_package} --save-exact"
  when :bun
    "bun add #{safe_package} --exact"
  when :npm
    "npm install #{safe_package} --save-exact"
  else # :yarn or unknown, default to yarn
    "yarn add #{safe_package} --exact"
  end
end

def self.package_manager_remove_command(package_name)

Returns:
  • (String) - The command to run (e.g., "yarn remove react-on-rails")

Parameters:
  • package_name (String) -- The name of the package to remove
def self.package_manager_remove_command(package_name)
  validate_package_name!(package_name)
  manager = detect_package_manager
  # Escape shell arguments to prevent command injection
  safe_package = Shellwords.escape(package_name)
  case manager
  when :pnpm
    "pnpm remove #{safe_package}"
  when :bun
    "bun remove #{safe_package}"
  when :npm
    "npm uninstall #{safe_package}"
  else # :yarn or unknown, default to yarn
    "yarn remove #{safe_package}"
  end
end

def self.prepend_cd_node_modules_directory(cmd)

def self.prepend_cd_node_modules_directory(cmd)
  "cd \"#{ReactOnRails.configuration.node_modules_location}\" && #{cmd}"
end

def self.prepend_to_file_if_text_not_present(file:, text_to_prepend:, regex:)

def self.prepend_to_file_if_text_not_present(file:, text_to_prepend:, regex:)
  if File.exist?(file)
    file_content = File.read(file)
    return if file_content.match(regex)
    content_with_prepended_text = text_to_prepend + file_content
    File.write(file, content_with_prepended_text, mode: "w")
  else
    File.write(file, text_to_prepend, mode: "w+")
  end
  puts "Prepended\n#{text_to_prepend}to #{file}."
end

def self.public_bundles_full_path

def self.public_bundles_full_path
  ReactOnRails::PackerUtils.packer_public_output_path
end

def self.rails_version_less_than(version)

def self.rails_version_less_than(version)
  @rails_version_less_than ||= {}
  return @rails_version_less_than[version] if @rails_version_less_than.key?(version)
  @rails_version_less_than[version] = begin
    Gem::Version.new(Rails.version) < Gem::Version.new(version)
  end
end

def self.react_on_rails_pro?

Raises:
  • (ReactOnRailsPro::Error) - if license is invalid

Returns:
  • (Boolean) - true if Pro is available with valid license
def self.react_on_rails_pro?
  return @react_on_rails_pro if defined?(@react_on_rails_pro)
  @react_on_rails_pro = begin
    return false unless gem_available?("react_on_rails_pro")
    ReactOnRailsPro::Utils.validated_license_data!.present?
  end
end

def self.react_on_rails_pro_version

Return an empty string if React on Rails Pro is not installed
def self.react_on_rails_pro_version
  return @react_on_rails_pro_version if defined?(@react_on_rails_pro_version)
  @react_on_rails_pro_version = if react_on_rails_pro?
                                  Gem.loaded_specs["react_on_rails_pro"].version.to_s
                                else
                                  ""
                                end
end

def self.rsc_support_enabled?

See react_on_rails_pro/lib/react_on_rails_pro/utils.rb
RSC support detection has been moved to React on Rails Pro
def self.rsc_support_enabled?
  return false unless react_on_rails_pro?
  ReactOnRailsPro::Utils.rsc_support_enabled?
end

def self.running_on_windows?

def self.running_on_windows?
  (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
end

def self.server_bundle?(bundle_name)

def self.server_bundle?(bundle_name)
ls.configuration
le_name == config.server_bundle_js_file
rations if Pro is available
ro?
tOnRailsPro.configuration
ndle_name == pro_config.rsc_bundle_js_file ||
ndle_name == pro_config.react_server_client_manifest_file

def self.server_bundle_js_file_path

def self.server_bundle_js_file_path
  return @server_bundle_path if @server_bundle_path && !Rails.env.development?
  bundle_name = ReactOnRails.configuration.server_bundle_js_file
  @server_bundle_path = bundle_js_file_path(bundle_name)
end

def self.server_bundle_path_is_http?

def self.server_bundle_path_is_http?
  server_bundle_js_file_path =~ %r{https?://}
end

def self.server_rendering_is_enabled?

def self.server_rendering_is_enabled?
  ReactOnRails.configuration.server_bundle_js_file.present?
end

def self.smart_trim(str, max_length = 1000)

def self.smart_trim(str, max_length = 1000)
  # From https://stackoverflow.com/a/831583/1009332
  str = str.to_s
  return str if full_text_errors_enabled?
  return str unless str.present? && max_length >= 1
  return str if str.length <= max_length
  return str[0, 1] + TRUNCATION_FILLER if max_length == 1
  midpoint = (str.length / 2.0).ceil
  to_remove = str.length - max_length
  lstrip = (to_remove / 2.0).ceil
  rstrip = to_remove - lstrip
  str[0..(midpoint - lstrip - 1)] + TRUNCATION_FILLER + str[(midpoint + rstrip)..]
end

def self.source_path

def self.source_path
  ReactOnRails::PackerUtils.packer_source_path
end

def self.truthy_presence(obj)

return object if truthy, else return nil
https://forum.shakacode.com/t/yak-of-the-week-ruby-2-4-pathname-empty-changed-to-look-at-file-size/901
def self.truthy_presence(obj)
  if obj.nil? || obj == false
    nil
  else
    obj
  end
end

def self.using_packer_source_path_is_not_defined_and_custom_node_modules?

def self.using_packer_source_path_is_not_defined_and_custom_node_modules?
  !ReactOnRails::PackerUtils.packer_source_path_explicit? &&
    ReactOnRails.configuration.node_modules_location.present?
end

def self.validate_package_command_inputs!(package_name, version)

Raises:
  • (ReactOnRails::Error) - if inputs contain potentially unsafe characters

Parameters:
  • version (String) -- The version to validate
  • package_name (String) -- The package name to validate
def self.validate_package_command_inputs!(package_name, version)
me!(package_name)
:Error, "version cannot be nil" if version.nil?
:Error, "version cannot be empty" if version.to_s.strip.empty?
r versions and common npm version patterns
3, 1.2.3-beta.1, 1.2.3-alpha, etc.
atch?(/\A[a-z0-9][a-z0-9._-]*\z/i)
:Error, "Invalid version: #{version.inspect}. " \
        "Versions must contain only alphanumeric characters, dots, hyphens, and underscores."

def self.validate_package_name!(package_name)

Raises:
  • (ReactOnRails::Error) - if package_name contains potentially unsafe characters

Parameters:
  • package_name (String) -- The package name to validate
def self.validate_package_name!(package_name)
:Error, "package_name cannot be nil" if package_name.nil?
:Error, "package_name cannot be empty" if package_name.to_s.strip.empty?
ackage names: alphanumeric, hyphens, underscores, dots, slashes (for scoped packages)
ub.com/npm/validate-npm-package-name
ame.match?(%r{\A[@a-z0-9][a-z0-9._/-]*\z}i)
:Error, "Invalid package name: #{package_name.inspect}. " \
        "Package names must contain only alphanumeric characters, " \
        "hyphens, underscores, dots, and slashes (for scoped packages)."

def self.wrap_message(msg, color = :red)

Pass in the msg and color as a symbol.
Wraps message and makes it colored.
def self.wrap_message(msg, color = :red)
  wrapper_line = ("=" * 80).to_s
  fenced_msg = <<~MSG
    #{wrapper_line}
    #{msg.strip}
    #{wrapper_line}
  MSG
  Rainbow(fenced_msg).color(color)
end