class ReactOnRails::VersionChecker::NodePackageVersion
rubocop:disable Metrics/ClassLength
def self.build
def self.build new(package_json_path, yarn_lock_path, package_lock_path) end
def self.package_json_path
def self.package_json_path Rails.root.join(ReactOnRails.configuration.node_modules_location, "package.json") end
def self.package_lock_path
def self.package_lock_path # Lockfiles are in the same directory as package.json # If node_modules_location is empty, use Rails.root base_dir = ReactOnRails.configuration.node_modules_location.presence || "" Rails.root.join(base_dir, "package-lock.json").to_s end
def self.yarn_lock_path
def self.yarn_lock_path # Lockfiles are in the same directory as package.json # If node_modules_location is empty, use Rails.root base_dir = ReactOnRails.configuration.node_modules_location.presence || "" Rails.root.join(base_dir, "yarn.lock").to_s end
def initialize(package_json, yarn_lock = nil, package_lock = nil)
def initialize(package_json, yarn_lock = nil, package_lock = nil) @package_json = package_json @yarn_lock = yarn_lock @package_lock = package_lock end
def local_path_or_url?
def local_path_or_url? # See https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies # All path and protocol "version ranges" include / somewhere, # but we want to make an exception for npm:@scope/pkg@version. !raw.nil? && raw.include?("/") && !raw.start_with?("npm:") end
def local_path_or_url_version?(version)
def local_path_or_url_version?(version) return false if version.nil? version.include?("/") && !version.start_with?("npm:") end
def package_installed?(package_name)
def package_installed?(package_name) return false unless File.exist?(package_json) parsed = parsed_package_contents parsed.dig("dependencies", package_name).present? end
def package_json_contents
def package_json_contents @package_json_contents ||= File.read(package_json) end
def package_name
def package_name return "react-on-rails-pro" if react_on_rails_pro_package? "react-on-rails" end
def parsed_package_contents
def parsed_package_contents return @parsed_package_contents if defined?(@parsed_package_contents) begin @parsed_package_contents = JSON.parse(package_json_contents) rescue JSON::ParserError => e raise ReactOnRails::Error, <<~MSG.strip **ERROR** ReactOnRails: Failed to parse package.json file. Location: #{package_json} Error: #{e.message} The package.json file contains invalid JSON. Please check the file for syntax errors. Common issues: - Missing or extra commas - Unquoted keys or values - Trailing commas (not allowed in JSON) - Comments (not allowed in standard JSON) MSG end end
def parts
def parts return if local_path_or_url? match = raw.match(VERSION_PARTS_REGEX) unless match raise ReactOnRails::Error, "Cannot parse version number '#{raw}' (only exact versions are supported)" end match.captures.compact end
def range_operator?
def range_operator? raw.start_with?(/[~^><*]/) end
def range_syntax?
def range_syntax? raw.include?(" - ") || raw.include?(" || ") end
def raw
def raw return @raw if defined?(@raw) return @raw = nil unless File.exist?(package_json) parsed = parsed_package_contents return @raw = nil unless parsed.key?("dependencies") deps = parsed["dependencies"] # Check for react-on-rails-pro first (Pro takes precedence) if deps.key?("react-on-rails-pro") @raw = resolve_version(deps["react-on-rails-pro"], "react-on-rails-pro") return @raw end # Fall back to react-on-rails if deps.key?("react-on-rails") @raw = resolve_version(deps["react-on-rails"], "react-on-rails") return @raw end # Neither package found msg = "No 'react-on-rails' or 'react-on-rails-pro' entry in the dependencies of " \ "#{NodePackageVersion.package_json_path}, which is the expected location according to " \ "ReactOnRails.configuration.node_modules_location" Rails.logger.warn(msg) @raw = nil end
def react_on_rails_package?
def react_on_rails_package? package_installed?("react-on-rails") end
def react_on_rails_pro_package?
def react_on_rails_pro_package? package_installed?("react-on-rails-pro") end
def resolve_version(package_json_version, package_name)
Resolve version from lockfiles if available, otherwise use package.json version
def resolve_version(package_json_version, package_name) # If package.json specifies a local path or URL, don't try to resolve from lockfiles # Lockfiles may contain placeholder versions like "0.0.0" for local links return package_json_version if local_path_or_url_version?(package_json_version) # Try yarn.lock first if yarn_lock && File.exist?(yarn_lock) lockfile_version = version_from_yarn_lock(package_name) return lockfile_version if lockfile_version end # Try package-lock.json if package_lock && File.exist?(package_lock) lockfile_version = version_from_package_lock(package_name) return lockfile_version if lockfile_version end # Fall back to package.json version package_json_version end
def semver_wildcard?
def semver_wildcard? # See https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies # We want to disallow all expressions other than exact versions # and the ones allowed by local_path_or_url? return true if raw.blank? special_version_string? || wildcard_or_x_range? || range_operator? || range_syntax? end
def special_version_string?
def special_version_string? %w[latest next canary beta alpha rc].include?(raw.downcase) end
def version_from_package_lock(package_name)
Supports both v1 (dependencies) and v2/v3 (packages) formats
Parse version from package-lock.json
def version_from_package_lock(package_name) return nil unless package_lock && File.exist?(package_lock) begin parsed = JSON.parse(File.read(package_lock)) # Try v2/v3 format first (packages) if parsed["packages"] # Look for node_modules/package-name entry node_modules_key = "node_modules/#{package_name}" package_data = parsed["packages"][node_modules_key] return package_data["version"] if package_data&.key?("version") end # Fall back to v1 format (dependencies) if parsed["dependencies"] dependency_data = parsed["dependencies"][package_name] # In v1, the dependency can be a hash with a "version" key return dependency_data["version"] if dependency_data.is_a?(Hash) && dependency_data.key?("version") end rescue JSON::ParserError # If we can't parse the lockfile, fall back to package.json version nil end nil end
def version_from_yarn_lock(package_name)
(e.g., "react-on-rails" won't match "react-on-rails-pro")
The pattern ensures exact package name match to avoid matching similar names
version "16.1.1"
react-on-rails@^16.1.1:
Looks for entries like:
Parse version from yarn.lock
def version_from_yarn_lock(package_name) return nil unless yarn_lock && File.exist?(yarn_lock) in_package_block = false File.foreach(yarn_lock) do |line| # Check if we're starting the block for our package # Pattern: optionally quoted package name, followed by @, ensuring it's not followed by more word chars # This prevents "react-on-rails" from matching "react-on-rails-pro" if line.match?(/^"?#{Regexp.escape(package_name)}@/) in_package_block = true next end # If we're in the package block, look for the version line if in_package_block # Version line looks like: version "16.1.1" if (match = line.match(/^\s+version\s+"([^"]+)"/)) return match[1] end # If we hit a blank line or new package, we've left the block break if line.strip.empty? || (line[0] != " " && line[0] != "\t") end end nil end
def wildcard_or_x_range?
def wildcard_or_x_range? raw == "*" || raw =~ /^[xX*]$/ || raw =~ /^[xX*]\./ || raw =~ /\.[xX*]\b/ || raw =~ /\.[xX*]$/ end