compiler_path = base_path('node_modules/@jqhtml/parser/bin/jqhtml-compile'); $this->cache_dir = storage_path('rsx-tmp/jqhtml-cache'); // Ensure cache directory exists if (!is_dir($this->cache_dir)) { mkdir($this->cache_dir, 0755, true); } // Validate compiler exists - MUST exist if (!file_exists($this->compiler_path)) { throw new \RuntimeException( "Official JQHTML CLI compiler not found at: {$this->compiler_path}. " . "Run 'npm install @jqhtml/parser@^2.2.59' to install the official CLI compiler." ); } } /** * Compile a single JQHTML template file * * @param string $file_path Path to .jqhtml file * @return string Compiled JavaScript code * @throws \RuntimeException On compilation failure */ public function compile_file(string $file_path): string { if (!file_exists($file_path)) { throw new \RuntimeException("JQHTML template not found: {$file_path}"); } // Get file modification time for cache key $mtime = filemtime($file_path); $cache_key = md5($file_path) . '_' . $mtime; $cache_file = $this->cache_dir . '/' . $cache_key . '.js'; // Check if cached version exists if (file_exists($cache_file)) { console_debug("JQHTML", "Using cached JQHTML template: {$file_path}"); return file_get_contents($cache_file); } console_debug("JQHTML", "Compiling JQHTML template: {$file_path}"); // Compile via RPC server $compiled_js = static::_compile_via_rpc($file_path); // Extract template variable name and append registration call // CRITICAL: Do NOT add extra newlines - they break sourcemap line offsets // The registration is appended AFTER the sourcemap comment so it doesn't affect mappings if (preg_match('/var\s+(template_\w+)\s*=/', $compiled_js, $matches)) { $template_var = $matches[1]; // Append registration on same line as end of sourcemap (no extra newlines) $compiled_js = rtrim($compiled_js) . "\njqhtml.register_template({$template_var});"; } $wrapped_js = $compiled_js; // Ensure exactly one newline at end (no extra) $wrapped_js = rtrim($wrapped_js) . "\n"; // Cache the compiled result file_put_contents($cache_file, $wrapped_js); // Clean up old cache files for this template $this->cleanup_old_cache($file_path, $cache_key); return $wrapped_js; } /** * Compile multiple JQHTML template files * * @param array $files Array of file paths * @return array Compiled JavaScript code keyed by file path */ public function compile_files(array $files): array { $compiled = []; foreach ($files as $file) { try { $compiled[$file] = $this->compile_file($file); } catch (\Exception $e) { // FAIL LOUD - don't continue on error throw new \RuntimeException( "Failed to compile JQHTML templates: " . $e->getMessage() ); } } return $compiled; } /** * Extract component name from file path * * @param string $file_path Path to .jqhtml file * @return string Component name */ protected function extract_component_name(string $file_path): string { // Remove base path and extension $relative = str_replace(base_path() . '/', '', $file_path); $relative = preg_replace('/\.jqhtml$/i', '', $relative); // Convert path to component name (e.g., rsx/app/components/MyComponent) // to MyComponent or components/MyComponent $parts = explode('/', $relative); // Use the filename as the component name return basename($relative); } /** * Clean up old cache files for a template * * @param string $file_path Original template path * @param string $current_cache_key Current cache key to keep */ protected function cleanup_old_cache(string $file_path, string $current_cache_key): void { $file_hash = md5($file_path); $pattern = $this->cache_dir . '/' . $file_hash . '_*.js'; foreach (glob($pattern) as $cache_file) { $cache_key = basename($cache_file, '.js'); if ($cache_key !== $current_cache_key) { unlink($cache_file); } } } /** * Clear all cached templates */ public function clear_cache(): void { $pattern = $this->cache_dir . '/*.js'; foreach (glob($pattern) as $cache_file) { unlink($cache_file); } console_debug("JQHTML", "Cleared JQHTML template cache"); } /** * Get cache statistics * * @return array Cache statistics */ public function get_cache_stats(): array { $pattern = $this->cache_dir . '/*.js'; $files = glob($pattern); $total_size = 0; foreach ($files as $file) { $total_size += filesize($file); } return [ 'cache_dir' => $this->cache_dir, 'cached_files' => count($files), 'total_size' => $total_size, 'total_size_human' => $this->format_bytes($total_size) ]; } /** * Format bytes to human readable * * @param int $bytes * @return string */ protected function format_bytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB']; $i = 0; while ($bytes >= 1024 && $i < count($units) - 1) { $bytes /= 1024; $i++; } return round($bytes, 2) . ' ' . $units[$i]; } /** * Start RPC server for JQHTML compilation * Lazy initialization - called automatically on first compile */ public static function start_rpc_server(): void { $socket_path = base_path(self::RPC_SOCKET); $server_script = base_path(self::RPC_SERVER_SCRIPT); if (!file_exists($server_script)) { throw new \RuntimeException("JQHTML Compile RPC server script not found at {$server_script}"); } // If socket exists, check if it's stale if (file_exists($socket_path)) { // Try to ping the existing server if (static::ping_rpc_server()) { // Server already running and responsive return; } // Socket exists but server not responding - force cleanup static::stop_rpc_server(force: true); } // Ensure socket directory exists $socket_dir = dirname($socket_path); if (!is_dir($socket_dir)) { mkdir($socket_dir, 0755, true); } // Start RPC server $process = new \Symfony\Component\Process\Process([ 'node', $server_script, '--socket=' . $socket_path ]); $process->setWorkingDirectory(base_path()); $process->setTimeout(null); // No timeout for long-running server $process->start(); static::$rpc_server_process = $process; // Wait for server to be ready (ping until it responds) $max_wait_ms = 10000; // 10 seconds $wait_interval_ms = 50; $iterations = $max_wait_ms / $wait_interval_ms; for ($i = 0; $i < $iterations; $i++) { usleep($wait_interval_ms * 1000); // Convert to microseconds if (static::ping_rpc_server()) { // Register shutdown handler to clean up server register_shutdown_function([self::class, 'stop_rpc_server']); return; } } // Timeout waiting for server throw new \RuntimeException( "JQHTML Compile RPC server failed to start within {$max_wait_ms}ms.\n" . "Check that Node.js and @jqhtml/parser are installed." ); } /** * Ping the RPC server to check if it's alive * * @return bool True if server responds, false otherwise */ public static function ping_rpc_server(): bool { $socket_path = base_path(self::RPC_SOCKET); if (!file_exists($socket_path)) { return false; } try { $socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1); if (!$socket) { return false; } // Set blocking mode for reliable reads/writes stream_set_blocking($socket, true); static::$request_id++; $request = json_encode([ 'id' => static::$request_id, 'method' => 'ping' ]) . "\n"; fwrite($socket, $request); $response = fgets($socket); fclose($socket); if (!$response) { return false; } $result = json_decode($response, true); return isset($result['result']) && $result['result'] === 'pong'; } catch (\Exception $e) { return false; } } /** * Stop the RPC server * * @param bool $force If true, forcefully kill the server (used for cleanup) */ public static function stop_rpc_server(bool $force = false): void { $socket_path = base_path(self::RPC_SOCKET); if ($force) { // Force cleanup: send shutdown, wait briefly, then SIGTERM if needed if (file_exists($socket_path)) { try { $socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1); if ($socket) { // Set blocking mode for reliable writes stream_set_blocking($socket, true); static::$request_id++; $request = json_encode([ 'id' => static::$request_id, 'method' => 'shutdown' ]) . "\n"; fwrite($socket, $request); fclose($socket); } } catch (\Exception $e) { // Ignore errors during force shutdown } // Wait briefly for graceful shutdown usleep(100000); // 100ms // Force remove socket @unlink($socket_path); } // Kill process if still running if (static::$rpc_server_process && static::$rpc_server_process->isRunning()) { static::$rpc_server_process->stop(0, SIGTERM); } } else { // Graceful shutdown: just send shutdown command if (file_exists($socket_path)) { try { $socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1); if ($socket) { // Set blocking mode for reliable writes stream_set_blocking($socket, true); static::$request_id++; $request = json_encode([ 'id' => static::$request_id, 'method' => 'shutdown' ]) . "\n"; fwrite($socket, $request); fclose($socket); } } catch (\Exception $e) { // Ignore errors during graceful shutdown } } } static::$rpc_server_process = null; } /** * Compile file via RPC server * * @param string $file_path Path to file to compile * @return string Compiled code */ protected static function _compile_via_rpc(string $file_path): string { // Start RPC server on first use (lazy initialization) if (static::$rpc_server_process === null) { static::start_rpc_server(); } $socket_path = base_path(self::RPC_SOCKET); if (!file_exists($socket_path)) { throw new \RuntimeException("JQHTML Compile RPC server socket not found at {$socket_path}"); } // Connect to RPC server $socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 5); if (!$socket) { throw new \RuntimeException("Failed to connect to JQHTML Compile RPC server: {$errstr}"); } // Set blocking mode for reliable reads/writes stream_set_blocking($socket, true); // Send compile request static::$request_id++; $request = json_encode([ 'id' => static::$request_id, 'method' => 'compile', 'files' => [ [ 'path' => $file_path, 'format' => 'iife', 'sourcemap' => true ] ] ]) . "\n"; fwrite($socket, $request); // Read response $response = fgets($socket); fclose($socket); if (!$response) { throw new \RuntimeException("JQHTML Compile RPC server returned empty response for {$file_path}"); } $result = json_decode($response, true); if (!$result || !isset($result['results'])) { throw new \RuntimeException( "JQHTML Compile RPC server returned invalid response for {$file_path}:\n" . $response ); } $file_result = $result['results'][$file_path] ?? null; if (!$file_result) { throw new \RuntimeException("JQHTML Compile RPC server did not return result for {$file_path}"); } // Handle error response if ($file_result['status'] === 'error' && isset($file_result['error'])) { $error = $file_result['error']; $message = $error['message'] ?? 'Unknown error'; $line = $error['line'] ?? null; $column = $error['column'] ?? null; // Throw appropriate exception type if ($line && $column) { throw new Jqhtml_Exception_ViewException( "JQHTML compilation failed:\n{$message}", $file_path, $line, $column ); } throw new \RuntimeException( "JQHTML compilation failed for {$file_path}:\n{$message}" ); } // Success - return compiled code if ($file_result['status'] === 'success' && isset($file_result['result'])) { return $file_result['result']; } // Unknown response format throw new \RuntimeException( "JQHTML Compile RPC server returned unexpected response for {$file_path}:\n" . json_encode($file_result, JSON_PRETTY_PRINT) ); } }