getPathname(); // Skip ignored directories if (in_array($path, $ignore_dirs)) { continue; } if ($item->isFile() || $item->isLink()) { unlink($path); } elseif ($item->isDir() && !in_array($path, $ignore_dirs)) { @rmdir($path); } } if ($delete_self) { return @rmdir($dir); } return true; } /** * Extract a list of values from a collection of arrays/objects * * @param array|object $array Collection to pluck from * @param string|null $key Key to extract (null for first element) * @return array Array of plucked values */ function array_pluck($array, $key = null) { $result = []; if (is_object($array)) { $array = (array) $array; } foreach ($array as $index => $item) { if (is_object($item)) { $item = (array) $item; } if ($key !== null) { if (isset($item[$key])) { $result[$index] = $item[$key]; } } else { // Get first element $result[$index] = reset($item); } } return $result; } /** * Get only specified keys from an array * * @param array $array Source array * @param array $keys Keys to keep * @return array Filtered array */ function array_only($array, $keys) { return array_intersect_key($array, array_flip($keys)); } /** * Get all except specified keys from an array * * @param array $array Source array * @param array $keys Keys to exclude * @return array Filtered array */ function array_except($array, $keys) { return array_diff_key($array, array_flip($keys)); } /** * Recursively merge arrays, combining array values instead of replacing * * Ported from array_intersperse() in RSpade v3. * * This function performs a deep merge of two arrays. Unlike array_merge_recursive() which * creates sub-arrays for duplicate keys, this function intelligently merges values: * - For numeric keys: appends unique values only (no duplicates) * - For string keys with array values: recursively merges the arrays * - For string keys with scalar values: replaces the target value with source value * * Accepts arrays by reference for efficiency but creates and returns a new merged array. * Does not modify the input arrays. * * @param array &$target Base array (not modified) * @param array &$source Array to merge (not modified) * @return array The merged result array * * @example * $target = [ * 'config' => ['debug' => true, 'env' => 'local'], * 'modules' => ['jquery', 'bootstrap'], * 'version' => '1.0' * ]; * $source = [ * 'config' => ['api_url' => '/api', 'env' => 'production'], * 'modules' => ['jquery', 'vue'], // jquery won't be duplicated * 'version' => '2.0' * ]; * $result = array_merge_deep($target, $source); * // Result in $result: * // [ * // 'config' => ['debug' => true, 'env' => 'production', 'api_url' => '/api'], * // 'modules' => ['jquery', 'bootstrap', 'vue'], * // 'version' => '2.0' * // ] */ function array_merge_deep(array &$target, array &$source): array { $result = $target; foreach ($source as $key => $value) { if (is_numeric($key)) { // Numeric key - append if not already present if (!in_array($value, $result, true)) { $result[] = $value; } } elseif (is_array($value)) { if (!isset($result[$key]) || !is_array($result[$key])) { $result[$key] = $value; } else { $result[$key] = array_merge_deep($result[$key], $value); } } else { $result[$key] = $value; } } return $result; } /** * Check if an array has string keys (is associative) * * @param array $array Array to check * @return bool True if has string keys */ function is_associative_array($array) { if (!is_array($array) || empty($array)) { return false; } return count(array_filter(array_keys($array), 'is_string')) > 0; } /** * Recursively convert objects to arrays * * @param mixed $object Object or value to convert * @return mixed Converted value */ function object_to_array_recursive($object) { if (is_object($object)) { // Check for to_array method if (method_exists($object, 'toArray')) { $object = $object->toArray(); } elseif (method_exists($object, 'to_array')) { $object = $object->to_array(); } else { $object = (array) $object; } } if (is_array($object)) { foreach ($object as $key => $value) { $object[$key] = object_to_array_recursive($value); } } return $object; } /** * Generate a cryptographically secure random hash * * @param int $bytes Number of random bytes (default: 32) * @return string Hexadecimal hash */ function random_hash($bytes = 32) { return bin2hex(random_bytes($bytes)); } /** * Make a string safe for use as a variable/function name * * @param string $string Input string * @param int $max_length Maximum length (default: 64) * @return string Safe string */ function safe_string($string, $max_length = 64) { // Replace non-alphanumeric with underscores $string = preg_replace('/[^a-zA-Z0-9_]+/', '_', $string); // Ensure first character is not a number if (empty($string) || is_numeric($string[0])) { $string = '_' . $string; } // Trim to max length return substr($string, 0, $max_length); } /** * Get file extension handling double extensions (e.g., .blade.php) * * @param string $path File path * @param bool $double Check for double extensions * @return string Extension */ function file_extension($path, $double = true) { $filename = basename($path); if ($double) { // Check for known double extensions $double_extensions = ['blade.php', 'spec.js', 'test.js', 'min.js', 'min.css', 'd.ts']; foreach ($double_extensions as $ext) { if (str_ends_with($filename, '.' . $ext)) { return $ext; } } } return pathinfo($filename, PATHINFO_EXTENSION); } /** * Get a view instance by RSX ID (path-agnostic identifier). * This allows views to be referenced by their ID instead of file path. * * @param string $id The ID defined in the view file with @rsx_id directive * @param array $data Data to pass to the view * @param array $merge_data Data to merge with the view data * @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory * @throws \Exception if the view is not found */ function rsx_view($id, $data = [], $merge_data = []) { // Use manifest to find the view file by ID $file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id); if (!$file_path) { throw new \InvalidArgumentException("RSX view not found: {$id}"); } // Share the ID for use in layouts (for body class) \Illuminate\Support\Facades\View::share('rsx_view_id', $id); // Share the current view path for bundle validation \Illuminate\Support\Facades\View::share('rsx_current_view_path', $file_path); // In development mode, validate the view and layout chain if (!app()->environment('production')) { // Validate the entire layout chain and bundle placement \App\RSpade\Core\Validation\LayoutChainValidator::validate_layout_chain($id); // Get the metadata for the view $metadata = \App\RSpade\Core\Manifest\Manifest::get_file($file_path); // Validate the view doesn't have inline styles/scripts \App\RSpade\Core\Validation\ViewValidator::validate_view($id, $file_path, $metadata ?? []); // If the view extends a layout, validate each layout in the chain has body class if (isset($metadata['rsx_extends'])) { $current_extends = $metadata['rsx_extends']; $visited = []; // Prevent infinite loops while ($current_extends) { if (in_array($current_extends, $visited)) { break; // Circular dependency, let LayoutChainValidator handle it } $visited[] = $current_extends; $layout_path = \App\RSpade\Core\Manifest\Manifest::find_view($current_extends); if ($layout_path) { // Validate this layout \App\RSpade\Core\Validation\ViewValidator::validate_layout($layout_path); // Get metadata to find next parent $layout_metadata = \App\RSpade\Core\Manifest\Manifest::get_file($layout_path); $current_extends = $layout_metadata['rsx_extends'] ?? null; } else { break; } } } } // Convert to Laravel view name format // Remove .blade.php extension $view_name = preg_replace('/\.blade\.php$/', '', $file_path); // Handle different view locations if (str_starts_with($view_name, 'resources/views/')) { $view_name = substr($view_name, strlen('resources/views/')); } elseif (str_starts_with($view_name, 'app/RSpade/')) { // For framework views, use namespace format // The namespace 'rspade' is registered in Rsx_Framework_Provider $view_name = 'rspade::' . substr($view_name, strlen('app/RSpade/')); } elseif (str_starts_with($view_name, 'rsx/')) { // For RSX views, use namespace format // The namespace 'rsx' is registered in RsxServiceProvider $view_name = str_replace('rsx/', 'rsx::', $view_name); } // Convert path separators to dots for Laravel $view_name = str_replace('/', '.', $view_name); return view($view_name, $data, $merge_data); } /** * Get the Laravel view path for a view by RSX ID. * Used internally by RSX Blade directives to resolve view paths. * * @param string $id The ID to look up * @return string|null The Laravel view path in dot notation or null if not found */ function rsx_view_path($id) { // Remove quotes if passed from Blade directive $id = trim($id, "'\""); // Use manifest to find the view file $file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id); if (!$file_path) { return null; } // Convert to Laravel view path format // Remove .blade.php extension $view_path = preg_replace('/\.blade\.php$/', '', $file_path); // Handle different view locations if (str_starts_with($view_path, 'resources/views/')) { $view_path = substr($view_path, strlen('resources/views/')); } elseif (str_starts_with($view_path, 'rsx/')) { // For RSX views, convert to namespace format $view_path = str_replace('rsx/', 'rsx::', $view_path); } // Convert path separators to dots return str_replace('/', '.', $view_path); } /** * Create directory if it doesn't exist * * @param string $path Directory path * @param int $permissions Directory permissions * @return bool Success status */ function ensure_directory($path, $permissions = 0755) { if (!is_dir($path)) { return mkdir($path, $permissions, true); } return true; } /** * Get relative path from base path * * @param string $path Full path * @param string|null $base Base path (defaults to base_path()) * @return string Relative path */ function relative_path($path, $base = null) { if ($base === null) { $base = base_path(); } $base = rtrim($base, '/') . '/'; if (str_starts_with($path, $base)) { return substr($path, strlen($base)); } return $path; } /** * Copy directory recursively * * @param string $source Source directory * @param string $destination Destination directory * @param array $ignore Patterns to ignore * @return bool Success status */ function copy_directory($source, $destination, $ignore = []) { if (!is_dir($source)) { return false; } if (!is_dir($destination)) { mkdir($destination, 0755, true); } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST, ); foreach ($iterator as $item) { $source_path = $item->getPathname(); $relative = relative_path($source_path, $source); $dest_path = $destination . '/' . $relative; // Check ignore patterns foreach ($ignore as $pattern) { if (fnmatch($pattern, $relative)) { continue 2; } } if ($item->isDir()) { if (!is_dir($dest_path)) { mkdir($dest_path, 0755, true); } } else { copy($source_path, $dest_path); } } return true; } /** * Recursively sort array by keys * * @param array $array Array to sort (by reference) * @param int $sort_flags Sort flags * @return bool Success status */ function ksort_recursive(&$array, $sort_flags = SORT_REGULAR) { if (!is_array($array)) { return false; } if (is_associative_array($array)) { ksort($array, $sort_flags); } else { sort($array, $sort_flags); } foreach ($array as &$value) { if (is_array($value)) { ksort_recursive($value, $sort_flags); } } return true; } /** * Convert snake_case to camelCase * * @param string $string Snake case string * @param bool $capitalize_first Whether to capitalize first letter * @return string Camel case string */ function snake_to_camel($string, $capitalize_first = false) { $result = str_replace('_', '', ucwords($string, '_')); if (!$capitalize_first) { $result = lcfirst($result); } return $result; } /** * Convert camelCase to snake_case * * @param string $string Camel case string * @return string Snake case string */ function camel_to_snake($string) { return strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($string))); } /** * Check if a value is "blank" (null, empty string, empty array, etc.) * * @param mixed $value Value to check * @return bool True if blank */ function is_blank($value) { if (is_null($value)) { return true; } if (is_string($value)) { return trim($value) === ''; } if (is_numeric($value) || is_bool($value)) { return false; } if ($value instanceof \Countable) { return count($value) === 0; } return empty($value); } /** * Get nested array value using "dot" notation * * @param array $array Source array * @param string $key Dot notation key * @param mixed $default Default value if not found * @return mixed Value or default */ function array_get($array, $key, $default = null) { if (is_null($key)) { return $array; } if (isset($array[$key])) { return $array[$key]; } foreach (explode('.', $key) as $segment) { if (!is_array($array) || !array_key_exists($segment, $array)) { return $default; } $array = $array[$segment]; } return $array; } /** * Set nested array value using "dot" notation * * @param array $array Target [by reference] * @param string $key Dot notation key * @param mixed $value Value to set * @return void */ function array_set(&$array, $key, $value) { if (is_null($key)) { $array = $value; return; } $keys = explode('.', $key); while (count($keys) > 1) { $key = array_shift($keys); if (!isset($array[$key]) || !is_array($array[$key])) { $array[$key] = []; } $array = &$array[$key]; } $array[array_shift($keys)] = $value; } /** * Flatten a multi-dimensional array into a single level * * @param array $array Array to flatten * @param int $depth Maximum depth to flatten * @return array Flattened array */ function array_flatten($array, $depth = INF) { $result = []; foreach ($array as $item) { if (!is_array($item)) { $result[] = $item; } elseif ($depth === 1) { $result = array_merge($result, array_values($item)); } else { $result = array_merge($result, array_flatten($item, $depth - 1)); } } return $result; } /** * Get the first element of an array that matches a condition * * @param array $array Source array * @param callable|null $callback Optional filter callback * @param mixed $default Default value if not found * @return mixed First element or default */ function array_first($array, $callback = null, $default = null) { if (is_null($callback)) { if (empty($array)) { return $default; } foreach ($array as $item) { return $item; } } foreach ($array as $key => $value) { if ($callback($value, $key)) { return $value; } } return $default; } /** * Get the last element of an array that matches a condition * * @param array $array Source array * @param callable|null $callback Optional filter callback * @param mixed $default Default value if not found * @return mixed Last element or default */ function array_last($array, $callback = null, $default = null) { if (is_null($callback)) { return empty($array) ? $default : end($array); } return array_first(array_reverse($array, true), $callback, $default); } /** * Create a temporary directory * * @param string $prefix Directory prefix * @param string $base Base directory (defaults to sys temp) * @return string|false Path to created directory or false on failure */ function make_temp_directory($prefix = 'rsx_', $base = null) { if ($base === null) { $base = sys_get_temp_dir(); } $attempts = 10; while ($attempts-- > 0) { $path = $base . '/' . $prefix . random_hash(8); if (mkdir($path, 0755)) { return $path; } } return false; } /** * Get the current site or a specific attribute from the current site. * * @param string|null $key * @return Site|mixed|null */ // Not yet implemented // function site($key = null) // { // if (is_null($key)) { // return \App\RSpade\Core\Session\Session::get_site(); // } // // $current_site = \App\RSpade\Core\Session\Session::get_site(); // return $current_site?->getAttribute($key); // } /** * Generate a URL for a specific site. * This function allows generating cross-site URLs by temporarily switching context. * * @param int $site_id * @param string $route * @param array $parameters * @param bool $absolute * @return string */ // Not yet implemented // function site_route($site_id, $route, $parameters = [], $absolute = true) // { // $original_site_id = \App\RSpade\Core\Session\Session::get_site_id(); // // try { // // Temporarily switch to target site context // \App\RSpade\Core\Session\Session::set_site_id($site_id); // // // Generate the route with the target site context // $url = route($route, $parameters, $absolute); // // return $url; // // } finally { // // Always restore original site context // \App\RSpade\Core\Session\Session::set_site_id($original_site_id); // } // } /** * Execute a shell command with pretty console output formatting. * Shows the command in gray with a carat prefix, then the output in default color. * * @param string $command The shell command to execute * @param bool $real_time Whether to show output in real-time (true) or after completion (false) * @param bool $throw_on_error Whether to throw an exception on non-zero exit code * @return array Returns array with 'output', 'error', and 'exit_code' */ function shell_exec_pretty($command, $real_time = true, $throw_on_error = false) { // ANSI color codes $gray = "\033[90m"; $reset = "\033[0m"; $red = "\033[31m"; // Display the command being run echo $gray . '> ' . $command . $reset . PHP_EOL; if ($real_time) { // Use passthru() for real-time output without proc_open() pipe buffer issues // Redirect to temp file to capture output for return value $temp_file = storage_path('rsx-tmp/shell_exec_pretty_' . uniqid() . '.txt'); // Use script command wrapper to show real-time output AND capture to file // passthru() shows output but doesn't capture it, so we use tee to do both $full_command = "($command 2>&1) | tee " . escapeshellarg($temp_file); // passthru() displays output in real-time and returns the exit code via $exit_code passthru($full_command, $exit_code); // Read captured output from file $output = ''; $error = ''; if (file_exists($temp_file)) { $output = file_get_contents($temp_file); unlink($temp_file); // Clean up } } else { // Use shell_exec for simple execution $full_command = $command . ' 2>&1'; $output = shell_exec($full_command); $exit_code = 0; // shell_exec doesn't provide exit codes $error = ''; // Display output if ($output !== null) { echo $output; } } // Check exit code if ($exit_code !== 0 && $throw_on_error) { $error_msg = "Command failed with exit code $exit_code: $command"; if ($error) { $error_msg .= "\nError output: $error"; } throw new RuntimeException($error_msg); } return [ 'output' => trim($output), 'error' => trim($error), 'exit_code' => $exit_code, ]; } /** * Check if a command exists in the system PATH. * * @param string $command * @return bool */ function command_exists($command) { $which = PHP_OS_FAMILY === 'Windows' ? 'where' : 'which'; $result = shell_exec("$which $command 2>&1"); return !empty($result) && !str_contains($result, 'not found') && !str_contains($result, 'Could not find'); } /** * Execute command without exec()'s output truncation issues * Drop-in replacement for exec() using shell_exec() and file redirection * * exec() has a critical flaw: it reads command output line-by-line into an array, * which can hit memory/buffer limits on large outputs (>1MB typical), causing * SILENT TRUNCATION without throwing errors or exceptions. * * \exec_safe() uses shell_exec() internally which handles unlimited output without * pipe buffer truncation issues, while maintaining the exact same signature as exec(). * * Usage: * // Before: * exec('git status 2>&1', $output, $return_var); * * // After: * \exec_safe('git status 2>&1', $output, $return_var); * * @param string $command Command to execute * @param array &$output Output lines (populated by reference like exec()) * @param int &$return_var Return code (populated by reference like exec()) * @return string|false Last line of output, or false on failure (like exec()) */ function exec_safe(string $command, array &$output = [], int &$return_var = 0): string|false { // Use shell_exec() for reliable output capture without pipe buffer truncation // shell_exec() doesn't provide exit codes, so use exec() with file redirection for that $temp_file = storage_path('rsx-tmp/exec_safe_' . uniqid() . '.txt'); // Redirect output to temp file to get both output and exit code reliably $full_command = "($command) > " . escapeshellarg($temp_file) . " 2>&1; echo $?"; // Execute and capture just the exit code (last line) $result = shell_exec($full_command); $return_var = (int)trim($result); // Read the full output from file $combined = ''; if (file_exists($temp_file)) { $combined = file_get_contents($temp_file); unlink($temp_file); // Clean up } if ($combined === false) { $return_var = -1; $output = []; return false; } // Split into lines like exec() does $output = $combined ? explode("\n", trim($combined)) : []; // Return last line like exec() does return empty($output) ? '' : end($output); } /** * Debug dump and die - Enhanced var_dump/die() replacement * * Outputs debug information with file/line location, values, and stack trace. * This is the preferred method for temporary debugging output. * * @param mixed ...$values Any number of values to debug * @return void (never returns - calls die()) */ function rsx_dump_die(...$values) { \App\RSpade\Core\Debug\Debugger::rsx_dump_die(...$values); } /** * Get the RSX ID of the current view for use as a body class * * This is used in layout files to add the view's RSX ID as a CSS class * to the body tag, enabling view-specific styling. * * @return string The RSX ID or empty string if not set */ function rsx_body_class() { return \Illuminate\Support\Facades\View::shared('rsx_view_id', ''); } /** * Create a unified error response * * @param string $error_code One of Ajax::ERROR_* constants * @param string|array|null $metadata Error message (string) or structured data (array) * @return \App\RSpade\Core\Response\Error_Response */ function response_error(string $error_code, $metadata = null) { return new \App\RSpade\Core\Response\Error_Response($error_code, $metadata); } /** * Create an unauthorized error response * * Context-aware response: * - Ajax requests: Returns JSON {success: false, error_code: 'unauthorized'} * - Web requests (not logged in): Eventually redirects to login (see wishlist 1.7.1) * - Web requests (logged in): Renders 403 error page or throws HttpException * * Use this when user is authenticated but lacks permission for an action, * OR when user is not authenticated and needs to be. * * @param string|null $message Custom error message (optional) * @return \App\RSpade\Core\Response\Error_Response */ function response_unauthorized(?string $message = null) { return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED, $message); } /** * Create a not found error response * * Context-aware response: * - Ajax requests: Returns JSON {success: false, error_code: 'not_found'} * - Web requests: Renders 404 error page or throws HttpException * * Use this when a requested resource does not exist. * * @param string|null $message Custom error message (optional) * @return \App\RSpade\Core\Response\Error_Response */ function response_not_found(?string $message = null) { return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, $message); } /** * Create a form validation error response * * Use this for validation errors with field-specific messages. * The client-side form handling will apply errors to matching fields. * * @param string $message Summary message for the error * @param array $field_errors Field-specific errors as ['field_name' => 'error message'] * @return \App\RSpade\Core\Response\Error_Response */ function response_form_error(string $message, array $field_errors = []) { return response_error( \App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, array_merge(['_message' => $message], $field_errors) ); } /** * Create an authentication required error response * * Use this when the user is not logged in and needs to authenticate. * Distinct from response_unauthorized() which is for permission denied. * * @param string|null $message Custom error message (optional) * @return \App\RSpade\Core\Response\Error_Response */ function response_auth_required(?string $message = null) { return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_AUTH_REQUIRED, $message); } /** * Create a fatal error response * * Use this for unrecoverable errors that prevent the operation from completing. * These are typically logged and displayed prominently to the user. * * @param string|null $message Error message * @param array $details Additional error details (e.g., file, line, backtrace) * @return \App\RSpade\Core\Response\Error_Response */ function response_fatal_error(?string $message = null, array $details = []) { $metadata = $details; if ($message !== null) { $metadata['_message'] = $message; } return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_FATAL, $metadata ?: $message); } /** * Check if the current request is from a loopback IP address * * Returns true only if: * - Request is from localhost, 127.0.0.1, or ::1 (IPv6 loopback) * - No proxy headers (X-Real-IP, X-Forwarded-For) are present * * Used to ensure Playwright test headers can only be used from local connections. * * @return bool True if request is from loopback without proxy headers */ function is_loopback_ip(): bool { $request = request(); if (!$request) { return false; } // Check for proxy headers - if present, not a direct loopback connection if ($request->hasHeader('X-Real-IP') || $request->hasHeader('X-Forwarded-For') || $request->hasHeader('X-Forwarded-Host') || $request->hasHeader('X-Forwarded-Proto')) { return false; } // Get the client IP $ip = $request->ip(); // Check for loopback addresses // IPv4 loopback: 127.0.0.1 // IPv6 loopback: ::1 // Hostname: localhost $loopback_addresses = [ '127.0.0.1', '::1', 'localhost', ]; return in_array($ip, $loopback_addresses, true); } /** * Sanitize HTML from WYSIWYG editors to prevent XSS attacks * * Uses HTMLPurifier to filter potentially malicious HTML while preserving * safe formatting tags. Suitable for user-generated rich text content. * * @param string $html The HTML string to sanitize * @return string Sanitized HTML safe for display */ function safe_html(string $html): string { static $purifier = null; if ($purifier === null) { require_once base_path('vendor/ezyang/htmlpurifier/library/HTMLPurifier.auto.php'); $config = HTMLPurifier_Config::createDefault(); // Cache serialized definitions for performance $cache_dir = storage_path('rsx-tmp/htmlpurifier'); if (!is_dir($cache_dir)) { mkdir($cache_dir, 0755, true); } $config->set('Cache.SerializerPath', $cache_dir); $config->set('Cache.SerializerPermissions', null); // Disable chmod (Docker compatibility) // Allow common formatting elements $config->set('HTML.Allowed', 'p,br,strong,b,em,i,u,s,strike,a[href|title|target],ul,ol,li,blockquote,h1,h2,h3,h4,h5,h6,pre,code,img[src|alt|title|width|height],table,thead,tbody,tr,th,td,div,span'); // Allow class attributes for styling $config->set('Attr.AllowedClasses', null); // Allow all classes // Link handling $config->set('HTML.TargetBlank', true); $config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]); $purifier = new HTMLPurifier($config); } return $purifier->purify($html); } /** * Generate a hash for a file suitable for build/cache invalidation * * Uses different strategies based on environment: * - Development: Fast hash based on path, size, and modification time * - Production: Thorough hash based on path and file contents * * This function is used by the manifest and bundle systems to detect * when files have changed and need recompilation. * * @param string $file_path The absolute path to the file * @return string A hash string representing the file state */ function _rsx_file_hash_for_build($file_path) { if (!file_exists($file_path)) { shouldnt_happen("File does not exist for hashing: {$file_path}"); } // In development mode, use a fast hash based on metadata if (!app()->environment('production')) { $hash_data = [ 'path' => $file_path, 'size' => filesize($file_path), 'mtime' => filemtime($file_path), ]; return md5(json_encode($hash_data)); } // In production mode, use a thorough hash based on content $hash_data = [ 'path' => $file_path, 'content' => file_get_contents($file_path), ]; return hash('sha512', json_encode($hash_data)); } /** * Convert text to HTML preserving whitespace and indentation * * Converts plain text to HTML that displays with proper formatting: * - HTML special characters are escaped * - Leading spaces on each line are converted to   * - Newlines are converted to
tags * - Trailing whitespace on lines is trimmed * * This is useful for displaying source code or formatted text in HTML * where you want to preserve the indentation and line breaks. * * @param string $text The plain text to convert * @return string HTML-formatted text */ function text_to_html_with_whitespace(string $text): string { // First, escape HTML special characters to prevent XSS $text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Split into lines $lines = explode("\n", $text); // Process each line $processed_lines = []; foreach ($lines as $line) { // Trim trailing whitespace only (preserve leading spaces) $line = rtrim($line); // Count leading spaces $leading_spaces = strlen($line) - strlen(ltrim($line)); if ($leading_spaces > 0) { // Replace leading spaces with   $spaces_html = str_repeat(' ', $leading_spaces); $line = $spaces_html . substr($line, $leading_spaces); } $processed_lines[] = $line; } // Join lines with
\n return implode("
\n", $processed_lines); } /** * Normalize a path resolving . and .. components without resolving symlinks * * Unlike PHP's realpath(), this function normalizes paths by resolving . and .. * components but does NOT follow symlinks. This is important when working with * symlinked directories where you need the logical path, not the physical path. * * Behavior: * - Resolves . (current directory) and .. (parent directory) components * - Does NOT resolve symlinks (unlike realpath()) * - Converts relative paths to absolute by prepending base_path() * - Returns normalized absolute path, or false if file doesn't exist * * Examples: * - rsxrealpath('/var/www/html/system/rsx/foo/../bar') * => '/var/www/html/system/rsx/bar' * * - rsxrealpath('rsx/foo/../bar') * => '/var/www/html/rsx/foo/../bar' (after base_path prepend) * => '/var/www/html/rsx/bar' * * - If /var/www/html/system/rsx is a symlink to /var/www/html/rsx: * rsxrealpath('/var/www/html/system/rsx/bar') * => '/var/www/html/system/rsx/bar' (keeps symlink path, unlike realpath) * * @param string $path The path to normalize * @return string|false The normalized absolute path, or false if file doesn't exist */ function rsxrealpath(string $path): string|false { // Convert relative path to absolute if (!str_starts_with($path, '/')) { $path = base_path() . '/' . $path; } // Split path into components $parts = explode('/', $path); $result = []; foreach ($parts as $part) { if ($part === '' || $part === '.') { // Skip empty parts and current directory references continue; } elseif ($part === '..') { // Go up one directory (remove last component) if (!empty($result)) { array_pop($result); } } else { // Regular path component $result[] = $part; } } // Rebuild path with leading slash $normalized = '/' . implode('/', $result); // Check if path exists (like realpath does) if (!file_exists($normalized)) { return false; } return $normalized; } /** * Convert bytes to human-readable format * * Converts a number of bytes into a human-readable string with appropriate unit suffix. * * @param int $bytes The number of bytes * @param int $precision Number of decimal places (default: 2) * @return string Formatted string (e.g., "1.5 MB", "342 B") */ function bytes_to_human($bytes, $precision = 2) { if (!is_numeric($bytes)) { return "---"; } $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { $bytes /= 1024; } return round($bytes, $precision) . ' ' . $units[$i]; } /** * Converts a duration from seconds to a human-readable format. * * @param int $seconds The duration in seconds. * @param bool $round_to_whole_value If true, returns only the largest unit * @return string The formatted duration. * * The function returns: * - "X seconds" if the duration is less than 60 seconds, * - "X minutes and Y seconds" if less than 10 minutes, * - "X minutes" if less than 1 hour, * - "X hours and Y minutes" otherwise. */ function duration_to_human($seconds, $round_to_whole_value = false) { if (!is_numeric($seconds)) { return "---"; } $years = intdiv($seconds, 31536000); // 365 days $months = intdiv($seconds % 31536000, 2592000); // 30 days $weeks = intdiv($seconds % 2592000, 604800); // 7 days $days = intdiv($seconds % 604800, 86400); // 24 hours $hours = intdiv($seconds % 86400, 3600); $minutes = intdiv($seconds % 3600, 60); $remaining_seconds = $seconds % 60; $parts = []; if ($years > 0) { $parts[] = $years . " year" . ($years != 1 ? "s" : ""); } if ($months > 0) { $parts[] = $months . " month" . ($months != 1 ? "s" : ""); } if ($weeks > 0) { $parts[] = $weeks . " week" . ($weeks != 1 ? "s" : ""); } if ($days > 0) { $parts[] = $days . " day" . ($days != 1 ? "s" : ""); } if ($hours > 0) { $parts[] = $hours . " hour" . ($hours != 1 ? "s" : ""); } if ($minutes > 0) { $parts[] = $minutes . " minute" . ($minutes != 1 ? "s" : ""); } if ($remaining_seconds > 0) { $parts[] = $remaining_seconds . " second" . ($remaining_seconds != 1 ? "s" : ""); } if ($round_to_whole_value) { return count($parts) > 0 ? $parts[0] : "less than a second"; } else { return count($parts) > 1 ? $parts[0] . " and " . $parts[1] : (count($parts) > 0 ? $parts[0] : "less than a second"); } } /** * Convert a full URL to short URL by removing protocol * * Strips http:// or https:// from the beginning of the URL if present. * Leaves the URL alone if it doesn't start with either protocol. * Removes trailing slash if there is no path. * * @param string|null $url URL to convert * @return string|null Short URL without protocol */ function full_url_to_short_url(?string $url): ?string { if ($url === null || $url === '') { return $url; } // Remove http:// or https:// from the beginning if (stripos($url, 'http://') === 0) { $url = substr($url, 7); } elseif (stripos($url, 'https://') === 0) { $url = substr($url, 8); } // Remove trailing slash if there is no path (just domain) // Check if URL is just domain with trailing slash (no path after slash) if (substr($url, -1) === '/' && substr_count($url, '/') === 1) { $url = rtrim($url, '/'); } return $url; } /** * Convert a short URL to full URL by adding protocol * * Adds http:// to the beginning of the URL if it lacks a protocol. * Leaves URLs with existing http:// or https:// unchanged. * Adds trailing slash if there is no path. * * @param string|null $url URL to convert * @return string|null Full URL with protocol */ function short_url_to_full_url(?string $url): ?string { if ($url === null || $url === '') { return $url; } // Check if URL already has a protocol if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) { $full_url = $url; } else { // Add http:// protocol $full_url = 'http://' . $url; } // Add trailing slash if there is no path (just domain) // Check if URL has no slash after the domain $without_protocol = preg_replace('#^https?://#i', '', $full_url); if (strpos($without_protocol, '/') === false) { $full_url .= '/'; } return $full_url; } /** * Validate a short URL format * * Validates that a URL (without protocol) looks like a valid domain. * Requirements: * - Must contain at least one dot (.) * - Must not contain spaces * - Empty strings are considered valid (optional field) * * @param string|null $url Short URL to validate * @return bool True if valid, false otherwise */ function validate_short_url(?string $url): bool { // Empty strings are valid (optional field) if ($url === null || $url === '') { return true; } // Must not contain spaces if (strpos($url, ' ') !== false) { return false; } // Must contain at least one dot (domain.extension) if (strpos($url, '.') === false) { return false; } return true; }